Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5179544fd6 | |||
| 160d6b49e9 | |||
| 9007762390 | |||
| 506007984c | |||
| 7a6d1a44f2 | |||
| 3960d746f4 | |||
| cc942ca818 | |||
| 83a2d25062 | |||
| c8429cfde1 | |||
| 1502039d70 | |||
| c326d2571e | |||
| e6df8fad98 | |||
| 8bd5042b5d | |||
| 568f571d8c | |||
| 8a9376b40e | |||
| 0c8aa4d1ea | |||
| 1d894f104f | |||
| fc3866924a | |||
| 368254d75d | |||
| 34756f8215 | |||
| 07ef106c66 | |||
| fd7dce7239 | |||
| b1878c684e | |||
| 83e105393c | |||
| 49288fa5c7 | |||
| dae9e18659 | |||
| 6923597b31 | |||
| d184a1732b | |||
| be84428226 | |||
| d9928030d6 | |||
| 76fc6fcdfc | |||
| 3169f49f23 | |||
| e0b35d230f | |||
| 4e2c36319d | |||
| 8474f78fe1 | |||
| 88896eddcf | |||
| a2874c0426 | |||
| 818aa0f1d6 | |||
| 55fd3021fb | |||
| 83b58f9207 | |||
| 602afa9b84 | |||
| 986f2fc7fa | |||
| 357f035418 | |||
| f340ce52ee | |||
| ecc477d0be | |||
| f9ba77527a | |||
| f304c70899 | |||
| 727d9494da | |||
| b60765785b | |||
| 28d6451265 | |||
| cabdc3df98 | |||
| f9ff04a354 | |||
| e611f26d32 | |||
| f097440f3c | |||
| c55d6c61fc | |||
| 32d6308eae | |||
| b97117e10d | |||
| abdce817f3 | |||
| f9d8a2e0ce | |||
| a7dfd5d502 | |||
| e310ba4156 | |||
| ae7adb0847 | |||
| d24510172e | |||
| 29a4e709d0 | |||
| 8a08e6a6ee | |||
| c0dba8e904 | |||
| b91859c258 | |||
| f1433b05a6 | |||
| f64694f894 | |||
| e86b14a677 | |||
| 98f3821f91 | |||
| 21a02da00f | |||
| 346f5cc1df | |||
| ef73586a41 | |||
| 9f79efdf36 | |||
| 4210f51937 | |||
| f41ae818ef | |||
| baf7e2d44d | |||
| 77ed2004f8 | |||
| 69d0f4972f | |||
| c7706d742f | |||
| 8937fb2804 | |||
| 77e9aa9b37 | |||
| 683ea2d8b1 | |||
| dd859c74a8 | |||
| b3c1519cf5 | |||
| 78fd702ccb | |||
| 0bc1bb1dd1 | |||
| c8968598e4 |
@@ -3,6 +3,7 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
@@ -28,25 +29,25 @@ jobs:
|
||||
publish:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
if: (github.ref == 'refs/heads/master' && github.event_name == 'push') || startsWith(github.ref, 'refs/tags/')
|
||||
concurrency:
|
||||
group: publish-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "npm"
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: npm run build
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "npm"
|
||||
|
||||
- name: Publish (skip if version already exists)
|
||||
run: |
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
@@ -54,7 +55,7 @@ jobs:
|
||||
if npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null; then
|
||||
echo "Version ${PKG_VERSION} already published — skipping."
|
||||
else
|
||||
npm publish --access public
|
||||
npm publish --provenance --access public
|
||||
fi
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> **Abandoned** — This adapter is no longer maintained. The Kubernetes execution capability has moved to the sandbox plugin at [`farhoodlabs/paperclip-plugin-k8s`](https://github.com/farhoodlabs/paperclip-plugin-k8s) (`@farhoodlabs/paperclip-plugin-k8s` on npm).
|
||||
|
||||
# Claude (Kubernetes) Paperclip Adapter Plugin
|
||||
|
||||
Paperclip adapter plugin that runs Claude Code agents as isolated Kubernetes Jobs instead of inside the main Paperclip process.
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/cli/format-event.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/cli</a> format-event.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">79.78% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>75/94</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">64.13% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>93/145</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>3/3</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">84.52% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>71/84</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import pc from "picocolors";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (typeof value === "string") return value;
|
||||
<span class="cstat-no" title="statement not covered" > if (typeof value !== "object" || value === null || Array.isArray(value)) <span class="cstat-no" title="statement not covered" >return "";</span></span>
|
||||
const obj = <span class="cstat-no" title="statement not covered" >value as Record<string, unknown>;</span>
|
||||
const message =
|
||||
(<span class="cstat-no" title="statement not covered" >typeof obj.message === "string" && obj.message) ||</span>
|
||||
(typeof obj.error === "string" && obj.error) ||
|
||||
(typeof obj.code === "string" && obj.code) ||
|
||||
"";
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (message) <span class="cstat-no" title="statement not covered" >return message;</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
<span class="cstat-no" title="statement not covered" > return JSON.stringify(obj);</span>
|
||||
} catch {
|
||||
<span class="cstat-no" title="statement not covered" > return "";</span>
|
||||
}
|
||||
}
|
||||
|
||||
function printToolResult(block: Record<string, unknown>): void {
|
||||
const isError = block.is_error === true;
|
||||
let text = "";
|
||||
if (typeof block.content === "string") {
|
||||
text = block.content;
|
||||
} else if (<span class="cstat-no" title="statement not covered" ><span class="missing-if-branch" title="else path not taken" >E</span>Array.isArray(block.content)) {</span>
|
||||
const parts: string[] = <span class="cstat-no" title="statement not covered" >[];</span>
|
||||
<span class="cstat-no" title="statement not covered" > for (const part of block.content) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (typeof part !== "object" || part === null || Array.isArray(part)) <span class="cstat-no" title="statement not covered" >continue;</span></span>
|
||||
const record = <span class="cstat-no" title="statement not covered" >part as Record<string, unknown>;</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (typeof record.text === "string") <span class="cstat-no" title="statement not covered" >parts.push(record.text);</span></span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > text = parts.join("\n");</span>
|
||||
}
|
||||
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (text) {
|
||||
console.log((isError ? pc.red : pc.gray)(text));
|
||||
}
|
||||
}
|
||||
|
||||
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
const model = typeof parsed.model === "string" ? parsed.model : <span class="branch-1 cbranch-no" title="branch not covered" >"unknown";</span>
|
||||
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : <span class="branch-1 cbranch-no" title="branch not covered" >""})</span>`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: <span class="branch-1 cbranch-no" title="branch not covered" >{};</span>
|
||||
const content = Array.isArray(message.content) ? message.content : <span class="branch-1 cbranch-no" title="branch not covered" >[];</span>
|
||||
for (const blockRaw of content) {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) <span class="cstat-no" title="statement not covered" >continue;</span>
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
const blockType = typeof block.type === "string" ? block.type : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
} else if (blockType === "thinking") {
|
||||
const text = typeof block.thinking === "string" ? block.thinking : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
} else if (<span class="missing-if-branch" title="else path not taken" >E</span>blockType === "tool_use") {
|
||||
const name = typeof block.name === "string" ? block.name : <span class="branch-1 cbranch-no" title="branch not covered" >"unknown";</span>
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (block.input !== undefined) {
|
||||
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: <span class="branch-1 cbranch-no" title="branch not covered" >{};</span>
|
||||
const content = Array.isArray(message.content) ? message.content : <span class="branch-1 cbranch-no" title="branch not covered" >[];</span>
|
||||
for (const blockRaw of content) {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) <span class="cstat-no" title="statement not covered" >continue;</span>
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (typeof block.type === "string" && block.type === "tool_result") {
|
||||
printToolResult(block);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage =
|
||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||
? (parsed.usage as Record<string, unknown>)
|
||||
: {};
|
||||
const input = Number(usage.input_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? 0);
|
||||
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
||||
const cost = Number(parsed.total_cost_usd ?? 0);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
const isError = parsed.is_error === true;
|
||||
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
||||
if (resultText) {
|
||||
console.log(pc.green("result:"));
|
||||
console.log(resultText);
|
||||
}
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
||||
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
||||
console.log(pc.red(`claude_result: subtype=${subtype || <span class="branch-1 cbranch-no" title="branch not covered" >"unknown"} </span>is_error=${isError ? "true" : <span class="branch-1 cbranch-no" title="branch not covered" >"false"}`))</span>;
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (errors.length > 0) {
|
||||
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
pc.blue(
|
||||
`tokens: in=${Number.isFinite(input) ? input : <span class="branch-1 cbranch-no" title="branch not covered" >0} </span>out=${Number.isFinite(output) ? output : <span class="branch-1 cbranch-no" title="branch not covered" >0} </span>cached=${Number.isFinite(cached) ? cached : <span class="branch-1 cbranch-no" title="branch not covered" >0} </span>cost=$${Number.isFinite(cost) ? cost.toFixed(6) : <span class="branch-1 cbranch-no" title="branch not covered" >"0.000000"}`,</span>
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/cli</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> src/cli</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">79.78% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>75/94</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">64.13% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>93/145</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>3/3</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">84.52% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>71/84</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file medium" data-value="format-event.ts"><a href="format-event.ts.html">format-event.ts</a></td>
|
||||
<td data-value="79.78" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 79%"></div><div class="cover-empty" style="width: 21%"></div></div>
|
||||
</td>
|
||||
<td data-value="79.78" class="pct medium">79.78%</td>
|
||||
<td data-value="94" class="abs medium">75/94</td>
|
||||
<td data-value="64.13" class="pct medium">64.13%</td>
|
||||
<td data-value="145" class="abs medium">93/145</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="3" class="abs high">3/3</td>
|
||||
<td data-value="84.52" class="pct high">84.52%</td>
|
||||
<td data-value="84" class="abs high">71/84</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file empty" data-value="index.ts"><a href="index.ts.html">index.ts</a></td>
|
||||
<td data-value="0" class="pic empty">
|
||||
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
|
||||
</td>
|
||||
<td data-value="0" class="pct empty">0%</td>
|
||||
<td data-value="0" class="abs empty">0/0</td>
|
||||
<td data-value="0" class="pct empty">0%</td>
|
||||
<td data-value="0" class="abs empty">0/0</td>
|
||||
<td data-value="0" class="pct empty">0%</td>
|
||||
<td data-value="0" class="abs empty">0/0</td>
|
||||
<td data-value="0" class="pct empty">0%</td>
|
||||
<td data-value="0" class="abs empty">0/0</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/cli/index.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/cli</a> index.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">0% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">0% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">0% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">0% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line low'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">export { printClaudeStreamEvent } from "./format-event.js";
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/server/config-schema.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> config-schema.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>2/2</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>1/1</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>2/2</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a>
|
||||
<a name='L148'></a><a href='#L148'>148</a>
|
||||
<a name='L149'></a><a href='#L149'>149</a>
|
||||
<a name='L150'></a><a href='#L150'>150</a>
|
||||
<a name='L151'></a><a href='#L151'>151</a>
|
||||
<a name='L152'></a><a href='#L152'>152</a>
|
||||
<a name='L153'></a><a href='#L153'>153</a>
|
||||
<a name='L154'></a><a href='#L154'>154</a>
|
||||
<a name='L155'></a><a href='#L155'>155</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">// NOTE: These types must match what Paperclip's SchemaConfigFields component
|
||||
// expects. Paperclip's server at GET /api/adapters/:type/config-schema
|
||||
// calls adapter.getConfigSchema() and the UI reads the JSON — types are only
|
||||
// used at build time here. The Paperclip types in @paperclipai/adapter-utils
|
||||
// may lag behind; these locals are the source of truth for this adapter.
|
||||
|
||||
interface ConfigFieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
type ConfigFieldSchema =
|
||||
| { type: "text"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
|
||||
| { type: "number"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
|
||||
| { type: "toggle"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
|
||||
| { type: "select"; key: string; label: string; hint?: string; options: ConfigFieldOption[]; default?: unknown; meta?: Record<string, unknown> }
|
||||
| { type: "textarea"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
|
||||
| { type: "combobox"; key: string; label: string; hint?: string; options?: ConfigFieldOption[]; default?: unknown; meta?: Record<string, unknown> };
|
||||
|
||||
interface AdapterConfigSchema {
|
||||
fields: ConfigFieldSchema[];
|
||||
}
|
||||
|
||||
export function getConfigSchema(): AdapterConfigSchema {
|
||||
// model, effort, instructionsFilePath, timeoutSec, graceSec are provided
|
||||
// by the platform UI and must not be duplicated here.
|
||||
const fields: ConfigFieldSchema[] = [
|
||||
// Core Claude fields
|
||||
{
|
||||
type: "number",
|
||||
key: "maxTurnsPerRun",
|
||||
label: "Max Turns Per Run",
|
||||
hint: "Maximum number of agentic turns (tool calls) per heartbeat run. 0 means unlimited.",
|
||||
default: 1000,
|
||||
},
|
||||
// Kubernetes
|
||||
{
|
||||
type: "text",
|
||||
key: "serviceAccountName",
|
||||
label: "Service Account",
|
||||
hint: "Service Account name for Job pods. Defaults to the cluster default.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "namespace",
|
||||
label: "Namespace",
|
||||
hint: "Kubernetes namespace for Jobs. Defaults to the Deployment namespace.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "image",
|
||||
label: "Container Image",
|
||||
hint: "Override the container image used for Job pods. Defaults to the running Deployment image.",
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "imagePullPolicy",
|
||||
label: "Image Pull Policy",
|
||||
hint: "Image pull policy for the container image.",
|
||||
options: [
|
||||
{ value: "IfNotPresent", label: "IfNotPresent" },
|
||||
{ value: "Always", label: "Always" },
|
||||
{ value: "Never", label: "Never" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "kubeconfig",
|
||||
label: "Kubeconfig Path",
|
||||
hint: "Absolute path to a kubeconfig file on disk. Defaults to in-cluster service account auth.",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
key: "ttlSecondsAfterFinished",
|
||||
label: "TTL Seconds After Finished",
|
||||
hint: "Auto-cleanup delay for completed Jobs in seconds. Default: 300.",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
key: "retainJobs",
|
||||
label: "Retain Jobs",
|
||||
hint: "Skip cleanup of completed Jobs for debugging purposes.",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
key: "reattachOrphanedJobs",
|
||||
label: "Reattach to Orphaned Jobs",
|
||||
hint: "If a prior K8s Job for the same agent/task/session is still running (e.g. Paperclip restarted mid-run), attach to it and stream its output instead of blocking the new run. When false, any non-terminal orphan blocks the new run. Default: on.",
|
||||
default: true,
|
||||
},
|
||||
// Resource Limits
|
||||
{
|
||||
type: "text",
|
||||
key: "resources.requests.cpu",
|
||||
label: "CPU Request",
|
||||
hint: "CPU request for Job pods (e.g. 100m, 0.5, 1).",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "resources.requests.memory",
|
||||
label: "Memory Request",
|
||||
hint: "Memory request for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "resources.limits.cpu",
|
||||
label: "CPU Limit",
|
||||
hint: "CPU limit for Job pods (e.g. 100m, 0.5, 1).",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "resources.limits.memory",
|
||||
label: "Memory Limit",
|
||||
hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
|
||||
},
|
||||
// Scheduling
|
||||
{
|
||||
type: "textarea",
|
||||
key: "nodeSelector",
|
||||
label: "Node Selector",
|
||||
hint: "Node selector for Job pods. One key=value per line (e.g. disktype=ssd).",
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
key: "tolerations",
|
||||
label: "Tolerations",
|
||||
hint: "Tolerations for Job pods as JSON array.",
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
key: "labels",
|
||||
label: "Labels",
|
||||
hint: "Extra labels added to Job metadata. One key=value per line.",
|
||||
},
|
||||
// Output filtering (RTK-compatible)
|
||||
{
|
||||
type: "toggle",
|
||||
key: "enableRtk",
|
||||
label: "Enable Output Filtering",
|
||||
hint: "Truncate oversized tool outputs before they reach the model, reducing token consumption. Implemented natively in Node.js — no external binary required. Installs a PostToolUse hook in ~/.claude/settings.json for each run.",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
key: "rtkMaxOutputBytes",
|
||||
label: "Max Tool Output Bytes",
|
||||
hint: "Maximum bytes of tool output to pass to the model when output filtering is enabled. Outputs exceeding this threshold are truncated with a summary. Default: 50000.",
|
||||
default: 50000,
|
||||
},
|
||||
];
|
||||
|
||||
return { fields };
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/server/log-dedup.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> log-dedup.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">89.33% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>67/75</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">80.32% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>49/61</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>6/6</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">95.08% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>58/61</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">147x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">82x</span>
|
||||
<span class="cline-any cline-yes">82x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">65x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">65x</span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-yes">21x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">44x</span>
|
||||
<span class="cline-any cline-yes">18x</span>
|
||||
<span class="cline-any cline-yes">18x</span>
|
||||
<span class="cline-any cline-yes">18x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">23x</span>
|
||||
<span class="cline-any cline-yes">19x</span>
|
||||
<span class="cline-any cline-yes">19x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">27x</span>
|
||||
<span class="cline-any cline-yes">27x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">39x</span>
|
||||
<span class="cline-any cline-yes">30x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">20x</span>
|
||||
<span class="cline-any cline-yes">20x</span>
|
||||
<span class="cline-any cline-yes">20x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">60x</span>
|
||||
<span class="cline-any cline-yes">60x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">60x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">60x</span>
|
||||
<span class="cline-any cline-yes">48x</span>
|
||||
<span class="cline-any cline-yes">48x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">/**
|
||||
* Line-level dedup filter for the K8s log stream.
|
||||
*
|
||||
* The K8s log follow stream can reconnect with an overlapping `sinceSeconds`
|
||||
* window (integer-second granularity + a safety buffer), which replays a few
|
||||
* seconds of recent output on every reconnect. Without dedup those replayed
|
||||
* lines appear as duplicate events in the streaming UI — the same assistant
|
||||
* text block shows up between every subsequent tool call (FAR-123).
|
||||
*
|
||||
* The filter operates at the chunk → line level: chunks are split on `\n`,
|
||||
* incomplete trailing content is buffered until the next chunk, and each
|
||||
* complete line is emitted at most once. JSON-shaped Claude stream-json
|
||||
* events are keyed by their stable structural IDs; non-JSON lines pass
|
||||
* through unchanged so genuinely-repeated status lines are not swallowed.
|
||||
*/
|
||||
|
||||
type Parsed = Record<string, unknown>;
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Parsed | null {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (typeof value !== "object" || value === null || Array.isArray(value)) <span class="cstat-no" title="statement not covered" >return null;</span>
|
||||
return value as Parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable dedup key for a Claude stream-json event. Returns `null`
|
||||
* when the event is not a recognized Claude event — those lines fall back to
|
||||
* raw-content hashing so non-JSON output (paperclip status lines, shell
|
||||
* output) is never deduped by identity.
|
||||
*/
|
||||
export function eventDedupKey(event: Parsed): string | null {
|
||||
const type = asString(event.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype);
|
||||
const sessionId = asString(event.session_id);
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (subtype === "init" && sessionId) return `system:init:${sessionId}`;
|
||||
<span class="cstat-no" title="statement not covered" > return null;</span>
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message = asRecord(event.message);
|
||||
const id = message ? asString(message.id) : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
if (id) return `assistant:${id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message = asRecord(event.message);
|
||||
const content = message && Array.isArray(message.content) ? message.content : <span class="branch-1 cbranch-no" title="branch not covered" >[];</span>
|
||||
const toolUseIds: string[] = [];
|
||||
for (const entry of content) {
|
||||
const block = asRecord(entry);
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!block) <span class="cstat-no" title="statement not covered" >continue;</span>
|
||||
const toolUseId = asString(block.tool_use_id);
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (toolUseId) toolUseIds.push(toolUseId);
|
||||
}
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (toolUseIds.length > 0) return `user:tool_result:${toolUseIds.join(",")}`;
|
||||
<span class="cstat-no" title="statement not covered" > return null;</span>
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const sessionId = asString(event.session_id);
|
||||
return sessionId ? `result:${sessionId}` : <span class="branch-1 cbranch-no" title="branch not covered" >"result:unknown";</span>
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful line-level dedup filter. Emits `filter(chunk)` output through
|
||||
* the caller — preserves original chunk formatting (including trailing
|
||||
* newlines) for lines that pass the dedup check.
|
||||
*/
|
||||
export class LogLineDedupFilter {
|
||||
private buffer = "";
|
||||
private readonly seenKeys = new Set<string>();
|
||||
|
||||
/**
|
||||
* Process a chunk and return the subset that should be forwarded.
|
||||
* Incomplete trailing content (no terminating newline) is buffered and
|
||||
* emitted on the next chunk that completes the line (or on flush()).
|
||||
*/
|
||||
filter(chunk: string): string {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!chunk) <span class="cstat-no" title="statement not covered" >return "";</span>
|
||||
const combined = this.buffer + chunk;
|
||||
const endsWithNewline = combined.endsWith("\n");
|
||||
const parts = combined.split("\n");
|
||||
|
||||
if (endsWithNewline) {
|
||||
// Discard the final empty element — last line was complete.
|
||||
parts.pop();
|
||||
this.buffer = "";
|
||||
} else {
|
||||
// Last element is an incomplete line — hold it for the next chunk.
|
||||
this.buffer = parts.pop() ?? <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const line of parts) {
|
||||
if (this.shouldEmit(line)) out.push(line);
|
||||
}
|
||||
if (out.length === 0) return "";
|
||||
return out.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any incomplete trailing content. Called when the stream ends
|
||||
* without a terminating newline so the final partial line isn't lost.
|
||||
*/
|
||||
flush(): string {
|
||||
const pending = this.buffer;
|
||||
this.buffer = "";
|
||||
if (!pending) return "";
|
||||
return this.shouldEmit(pending) ? pending : "";
|
||||
}
|
||||
|
||||
private shouldEmit(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!trimmed) <span class="cstat-no" title="statement not covered" >return true;</span>
|
||||
|
||||
// Only attempt dedup on JSON-shaped lines; pass shell/text output through.
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return true;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
<span class="cstat-no" title="statement not covered" > return true;</span>
|
||||
}
|
||||
|
||||
const event = asRecord(parsed);
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!event) <span class="cstat-no" title="statement not covered" >return true;</span>
|
||||
|
||||
// Recognized Claude stream-json event → structural key.
|
||||
const structuralKey = eventDedupKey(event);
|
||||
const key = structuralKey ?? `raw:${trimmed}`;
|
||||
|
||||
if (this.seenKeys.has(key)) return false;
|
||||
this.seenKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/server/models.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> models.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>4/4</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>6/6</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>2/2</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>4/4</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
|
||||
const DIRECT_MODELS: AdapterModel[] = [
|
||||
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
||||
];
|
||||
|
||||
const BEDROCK_MODELS: AdapterModel[] = [
|
||||
{ id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" },
|
||||
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
|
||||
{ id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
|
||||
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "Bedrock Sonnet 4.5" },
|
||||
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
|
||||
];
|
||||
|
||||
function isBedrockEnv(): boolean {
|
||||
return (
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
|
||||
(typeof process.env.ANTHROPIC_BEDROCK_BASE_URL === "string" &&
|
||||
process.env.ANTHROPIC_BEDROCK_BASE_URL.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export async function listK8sModels(): Promise<AdapterModel[]> {
|
||||
return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/server/prompt-cache.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> prompt-cache.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">34.88% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>30/86</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">47.82% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>22/46</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">30.76% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>4/13</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">34.66% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>26/75</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line low'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a>
|
||||
<a name='L148'></a><a href='#L148'>148</a>
|
||||
<a name='L149'></a><a href='#L149'>149</a>
|
||||
<a name='L150'></a><a href='#L150'>150</a>
|
||||
<a name='L151'></a><a href='#L151'>151</a>
|
||||
<a name='L152'></a><a href='#L152'>152</a>
|
||||
<a name='L153'></a><a href='#L153'>153</a>
|
||||
<a name='L154'></a><a href='#L154'>154</a>
|
||||
<a name='L155'></a><a href='#L155'>155</a>
|
||||
<a name='L156'></a><a href='#L156'>156</a>
|
||||
<a name='L157'></a><a href='#L157'>157</a>
|
||||
<a name='L158'></a><a href='#L158'>158</a>
|
||||
<a name='L159'></a><a href='#L159'>159</a>
|
||||
<a name='L160'></a><a href='#L160'>160</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
type PaperclipSkillEntry,
|
||||
ensurePaperclipSkillSymlink,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
export interface ClaudePromptBundle {
|
||||
bundleKey: string;
|
||||
/** Absolute path to the bundle root directory (contains .claude/skills/ and agent-instructions.md). */
|
||||
rootDir: string;
|
||||
/** Value to pass as --add-dir to the Claude CLI. */
|
||||
addDir: string;
|
||||
/** Path to the materialized instructions file, or null if no instructions were provided. */
|
||||
instructionsFilePath: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
|
||||
function validatePathComponent(value: string, fieldName: string): void {
|
||||
if (value.trim().length === 0) throw new Error(`Invalid ${fieldName}: must not be empty`);
|
||||
if (value.includes("/") || value.includes("\\")) throw new Error(`Invalid ${fieldName}: must not contain path separators`);
|
||||
if (value.includes("..")) throw new Error(`Invalid ${fieldName}: must not contain ".."`);
|
||||
if (value.includes("\0")) throw new Error(`Invalid ${fieldName}: must not contain null bytes`);
|
||||
}
|
||||
|
||||
function resolveManagedClaudePromptCacheRoot(companyId: string): string {
|
||||
const paperclipHome =
|
||||
(typeof process.env.PAPERCLIP_HOME === "string" && process.env.PAPERCLIP_HOME.trim().length > 0
|
||||
? process.env.PAPERCLIP_HOME.trim()
|
||||
: <span class="branch-1 cbranch-no" title="branch not covered" >null) ??</span>
|
||||
<span class="branch-1 cbranch-no" title="branch not covered" > path.resolve(os.homedir(), ".paperclip");</span>
|
||||
const instanceId =
|
||||
(typeof process.env.PAPERCLIP_INSTANCE_ID === "string" && process.env.PAPERCLIP_INSTANCE_ID.trim().length > 0
|
||||
? process.env.PAPERCLIP_INSTANCE_ID.trim()
|
||||
: <span class="branch-1 cbranch-no" title="branch not covered" >null) ?? <span class="branch-1 cbranch-no" title="branch not covered" >D</span>EFAULT_PAPERCLIP_INSTANCE_ID;</span>
|
||||
validatePathComponent(companyId, "companyId");
|
||||
validatePathComponent(instanceId, "instanceId");
|
||||
return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache");
|
||||
}
|
||||
|
||||
async function <span class="fstat-no" title="function not covered" >hashPathContents(</span>
|
||||
candidate: string,
|
||||
hash: ReturnType<typeof createHash>,
|
||||
relativePath: string,
|
||||
seenDirectories: Set<string>,
|
||||
): Promise<void> {
|
||||
const stat = <span class="cstat-no" title="statement not covered" >await fs.lstat(candidate);</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (stat.isSymbolicLink()) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update(`symlink:${relativePath}\n`);</span>
|
||||
const resolved = <span class="cstat-no" title="statement not covered" >await fs.realpath(candidate).<span class="fstat-no" title="function not covered" >catch(() => <span class="cstat-no" title="statement not covered" >n</span>ull);</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > if (!resolved) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update("missing\n");</span>
|
||||
<span class="cstat-no" title="statement not covered" > return;</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > await hashPathContents(resolved, hash, relativePath, seenDirectories);</span>
|
||||
<span class="cstat-no" title="statement not covered" > return;</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > if (stat.isDirectory()) {</span>
|
||||
const realDir = <span class="cstat-no" title="statement not covered" >await fs.realpath(candidate).<span class="fstat-no" title="function not covered" >catch(() => <span class="cstat-no" title="statement not covered" >c</span>andidate);</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update(`dir:${relativePath}\n`);</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (seenDirectories.has(realDir)) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update("loop\n");</span>
|
||||
<span class="cstat-no" title="statement not covered" > return;</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > seenDirectories.add(realDir);</span>
|
||||
const entries = <span class="cstat-no" title="statement not covered" >await fs.readdir(candidate, { withFileTypes: true });</span>
|
||||
<span class="cstat-no" title="statement not covered" > entries.<span class="fstat-no" title="function not covered" >sort((a</span>, b) => <span class="cstat-no" title="statement not covered" >a.name.localeCompare(b.name))</span>;</span>
|
||||
<span class="cstat-no" title="statement not covered" > for (const entry of entries) {</span>
|
||||
const childRelativePath = <span class="cstat-no" title="statement not covered" >relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name;</span>
|
||||
<span class="cstat-no" title="statement not covered" > await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return;</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > if (stat.isFile()) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update(`file:${relativePath}\n`);</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update(await fs.readFile(candidate));</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update("\n");</span>
|
||||
<span class="cstat-no" title="statement not covered" > return;</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > hash.update(`other:${relativePath}:${stat.mode}\n`);</span>
|
||||
}
|
||||
|
||||
async function buildClaudePromptBundleKey(input: {
|
||||
skills: PaperclipSkillEntry[];
|
||||
instructionsContents: string | null;
|
||||
}): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
hash.update("paperclip-claude-prompt-bundle:v1\n");
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (input.instructionsContents) {
|
||||
<span class="cstat-no" title="statement not covered" > hash.update("instructions\n");</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update(input.instructionsContents);</span>
|
||||
<span class="cstat-no" title="statement not covered" > hash.update("\n");</span>
|
||||
} else {
|
||||
hash.update("instructions:none\n");
|
||||
}
|
||||
const sortedSkills = [...input.skills].<span class="fstat-no" title="function not covered" >sort((a</span>, b) => <span class="cstat-no" title="statement not covered" >a.runtimeName.localeCompare(b.runtimeName))</span>;
|
||||
for (const entry of sortedSkills) {
|
||||
<span class="cstat-no" title="statement not covered" > hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);</span>
|
||||
<span class="cstat-no" title="statement not covered" > await hashPathContents(entry.source, hash, entry.runtimeName, new Set());</span>
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function <span class="fstat-no" title="function not covered" >ensureReadableFile(t</span>argetPath: string, contents: string): Promise<void> {
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
<span class="cstat-no" title="statement not covered" > await fs.access(targetPath, fsConstants.R_OK);</span>
|
||||
<span class="cstat-no" title="statement not covered" > return;</span>
|
||||
} catch {
|
||||
// Fall through and materialize the file.
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > await fs.mkdir(path.dirname(targetPath), { recursive: true });</span>
|
||||
const tempPath = <span class="cstat-no" title="statement not covered" >`${targetPath}.${process.pid}.${Date.now()}.tmp`;</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
<span class="cstat-no" title="statement not covered" > await fs.writeFile(tempPath, contents, "utf8");</span>
|
||||
<span class="cstat-no" title="statement not covered" > await fs.rename(tempPath, targetPath);</span>
|
||||
} catch (err) {
|
||||
const targetReadable = <span class="cstat-no" title="statement not covered" >await fs.access(targetPath, fsConstants.R_OK).<span class="fstat-no" title="function not covered" >then(() => <span class="cstat-no" title="statement not covered" >t</span>rue).<span class="fstat-no" title="function not covered" ></span>catch(() => <span class="cstat-no" title="statement not covered" >f</span>alse);</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > if (!targetReadable) <span class="cstat-no" title="statement not covered" >throw err;</span></span>
|
||||
} finally {
|
||||
<span class="cstat-no" title="statement not covered" > await fs.rm(tempPath, { force: true }).<span class="fstat-no" title="function not covered" >catch(() => {</span>});</span>
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareClaudePromptBundle(input: {
|
||||
companyId: string;
|
||||
skills: PaperclipSkillEntry[];
|
||||
instructionsContents: string | null;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<ClaudePromptBundle> {
|
||||
const { companyId, skills, instructionsContents, onLog } = input;
|
||||
const bundleKey = await buildClaudePromptBundleKey({ skills, instructionsContents });
|
||||
const rootDir = path.join(resolveManagedClaudePromptCacheRoot(companyId), bundleKey);
|
||||
const skillsHome = path.join(rootDir, ".claude", "skills");
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
|
||||
for (const entry of skills) {
|
||||
const target = <span class="cstat-no" title="statement not covered" >path.join(skillsHome, entry.runtimeName);</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
<span class="cstat-no" title="statement not covered" > await ensurePaperclipSkillSymlink(entry.source, target);</span>
|
||||
} catch (err) {
|
||||
<span class="cstat-no" title="statement not covered" > await onLog(</span>
|
||||
"stderr",
|
||||
`[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const instructionsFilePath = instructionsContents ? <span class="branch-0 cbranch-no" title="branch not covered" >path.join(rootDir, "agent-instructions.md") </span>: null;
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (instructionsFilePath && <span class="branch-1 cbranch-no" title="branch not covered" >instructionsContents) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > await ensureReadableFile(instructionsFilePath, instructionsContents);</span>
|
||||
}
|
||||
|
||||
return { bundleKey, rootDir, addDir: rootDir, instructionsFilePath };
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/server/skills.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> skills.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>25/25</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">88.88% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>16/18</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>7/7</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>19/19</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillSnapshot,
|
||||
AdapterSkillEntry,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
readInstalledSkillTargets,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
|
||||
const SKILLS_HOME = "/paperclip/.claude/skills";
|
||||
|
||||
async function buildK8sSkillSnapshot(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? <span class="branch-1 cbranch-no" title="branch not covered" >__dirname);</span>
|
||||
const availableByKey = new Map(availableEntries.map((e) => [e.key, e]));
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const installed = await readInstalledSkillTargets(SKILLS_HOME);
|
||||
|
||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired: desiredSet.has(entry.key),
|
||||
managed: true,
|
||||
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.key)
|
||||
? "Materialized into the PVC-backed Claude prompt bundle before each K8s Job run."
|
||||
: null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
}));
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: undefined,
|
||||
targetPath: undefined,
|
||||
detail: "Paperclip cannot find this skill in the runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableEntries.some((e) => e.runtimeName === name)) continue;
|
||||
entries.push({
|
||||
key: name,
|
||||
runtimeName: name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: "~/.claude/skills",
|
||||
readOnly: true,
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? <span class="branch-1 cbranch-no" title="branch not covered" >path.join(SKILLS_HOME, name),</span>
|
||||
detail: "Installed outside Paperclip management in the Claude skills home.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.key.localeCompare(b.key));
|
||||
|
||||
return {
|
||||
adapterType: "claude_k8s",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listK8sSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildK8sSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncK8sSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
return buildK8sSkillSnapshot(ctx.config);
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-04-24T04:09:41.748Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+33
@@ -1,3 +1,36 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import type * as k8s from "@kubernetes/client-node";
|
||||
/**
|
||||
* Detect a Kubernetes 404 (Not Found) error from @kubernetes/client-node.
|
||||
* Works for both v0.x (response.statusCode) and v1.0+ (response.status, message).
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export declare function isK8s404(err: unknown): boolean;
|
||||
/**
|
||||
* Build the error message when Claude's stdout contains no result event.
|
||||
* Skips system/init event lines so the UI doesn't display the raw init JSON.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export declare function buildPartialRunError(exitCode: number | null, model: string, stdout: string): string;
|
||||
/**
|
||||
* Evaluate an orphaned K8s Job (one whose `paperclip.io/run-id` label does
|
||||
* not match the current runId) as a potential reattach target. A Job is
|
||||
* reattachable when it belongs to the same agent, same task, and same resume
|
||||
* session as the current run — meaning the previous Paperclip instance was
|
||||
* mid-stream on the exact piece of work this new run was dispatched to do.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export declare function isReattachableOrphan(job: k8s.V1Job, expected: {
|
||||
agentId: string;
|
||||
taskId: string | null;
|
||||
sessionId: string | null;
|
||||
}): boolean;
|
||||
/**
|
||||
* Build an error message for a pod that reached phase=Failed before or
|
||||
* instead of streaming logs. Includes the claude container's terminated exit
|
||||
* code and reason when available so operators can diagnose crashes without
|
||||
* needing kubectl. Exported for unit tests.
|
||||
*/
|
||||
export declare function describePodTerminatedError(podName: string, phase: string, containerStatuses: k8s.V1ContainerStatus[]): string;
|
||||
export declare function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
//# sourceMappingURL=execute.d.ts.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAiUlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAoc3F"}
|
||||
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAWlG,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAYpD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAO9C;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,GACb,MAAM,CA4BR;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,GAAG,CAAC,KAAK,EACd,QAAQ,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAC7E,OAAO,CAaT;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,iBAAiB,EAAE,GAAG,CAAC,iBAAiB,EAAE,GACzC,MAAM,CASR;AAkWD,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAkkB3F"}
|
||||
Vendored
+370
-104
@@ -1,12 +1,110 @@
|
||||
import { asString, asNumber, asBoolean, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { parseClaudeStreamJson, describeClaudeFailure, isClaudeMaxTurnsResult, isClaudeUnknownSessionError, } from "./parse.js";
|
||||
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js";
|
||||
import { buildJobManifest } from "./job-manifest.js";
|
||||
import { buildJobManifest, sanitizeLabelValue } from "./job-manifest.js";
|
||||
import { LogLineDedupFilter } from "./log-dedup.js";
|
||||
import { Writable } from "node:stream";
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const KEEPALIVE_INTERVAL_MS = 15_000;
|
||||
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
|
||||
const MAX_LOG_RECONNECT_ATTEMPTS = 50;
|
||||
// How long to keep refreshing onSpawn after the Job reaches a terminal state.
|
||||
// Covers the cleanup path (delete job, parse stdout) so a slow K8s API call
|
||||
// doesn't trip the 5-minute reaper staleness window.
|
||||
const POST_TERMINAL_KEEPALIVE_MS = 90_000;
|
||||
/**
|
||||
* Detect a Kubernetes 404 (Not Found) error from @kubernetes/client-node.
|
||||
* Works for both v0.x (response.statusCode) and v1.0+ (response.status, message).
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function isK8s404(err) {
|
||||
if (!(err instanceof Error))
|
||||
return false;
|
||||
const e = err;
|
||||
const resp = e.response;
|
||||
if (resp?.statusCode === 404 || resp?.status === 404)
|
||||
return true;
|
||||
if (e.statusCode === 404)
|
||||
return true;
|
||||
return /HTTP-Code:\s*404\b/.test(err.message);
|
||||
}
|
||||
/**
|
||||
* Build the error message when Claude's stdout contains no result event.
|
||||
* Skips system/init event lines so the UI doesn't display the raw init JSON.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function buildPartialRunError(exitCode, model, stdout) {
|
||||
if (exitCode === 0)
|
||||
return "Failed to parse Claude JSON output";
|
||||
// Walk stdout lines, skip system events, return the first real content line.
|
||||
const firstContentLine = stdout.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => {
|
||||
if (!l)
|
||||
return false;
|
||||
try {
|
||||
const obj = JSON.parse(l);
|
||||
if (typeof obj === "object" && obj !== null && obj.type === "system")
|
||||
return false;
|
||||
}
|
||||
catch {
|
||||
// not JSON — treat as content
|
||||
}
|
||||
return true;
|
||||
}) ?? "";
|
||||
// If we only have system/init events and nothing else, surface the model
|
||||
// name so the operator can diagnose missing credentials or unsupported model.
|
||||
const initOnlyOutput = stdout.trim() !== "" && model !== "" && !firstContentLine;
|
||||
if (initOnlyOutput) {
|
||||
const modelHint = model ? ` (model: ${model})` : "";
|
||||
return `Claude started but did not produce a result${modelHint} — check API credentials, model support, and adapter config`;
|
||||
}
|
||||
return firstContentLine
|
||||
? `Claude exited with code ${exitCode ?? -1}: ${firstContentLine}`
|
||||
: `Claude exited with code ${exitCode ?? -1}`;
|
||||
}
|
||||
/**
|
||||
* Evaluate an orphaned K8s Job (one whose `paperclip.io/run-id` label does
|
||||
* not match the current runId) as a potential reattach target. A Job is
|
||||
* reattachable when it belongs to the same agent, same task, and same resume
|
||||
* session as the current run — meaning the previous Paperclip instance was
|
||||
* mid-stream on the exact piece of work this new run was dispatched to do.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function isReattachableOrphan(job, expected) {
|
||||
if (!expected.taskId || !expected.sessionId)
|
||||
return false;
|
||||
const labels = job.metadata?.labels ?? {};
|
||||
if (labels["paperclip.io/adapter-type"] !== "claude_k8s")
|
||||
return false;
|
||||
if (labels["paperclip.io/agent-id"] !== expected.agentId)
|
||||
return false;
|
||||
if (labels["paperclip.io/task-id"] !== expected.taskId)
|
||||
return false;
|
||||
if (labels["paperclip.io/session-id"] !== expected.sessionId)
|
||||
return false;
|
||||
const conditions = job.status?.conditions ?? [];
|
||||
const terminal = conditions.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True");
|
||||
if (terminal)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Build an error message for a pod that reached phase=Failed before or
|
||||
* instead of streaming logs. Includes the claude container's terminated exit
|
||||
* code and reason when available so operators can diagnose crashes without
|
||||
* needing kubectl. Exported for unit tests.
|
||||
*/
|
||||
export function describePodTerminatedError(podName, phase, containerStatuses) {
|
||||
const mainCs = containerStatuses.find((cs) => cs.name === "claude");
|
||||
const terminated = mainCs?.state?.terminated;
|
||||
if (terminated) {
|
||||
const code = terminated.exitCode ?? "unknown";
|
||||
const reason = terminated.reason ?? terminated.message ?? "no reason";
|
||||
return `Pod ${podName} reached phase=${phase}: claude exited ${code} (${reason})`;
|
||||
}
|
||||
return `Pod ${podName} reached phase=${phase}`;
|
||||
}
|
||||
/**
|
||||
* Wait for the Job's pod to reach a terminal or running state.
|
||||
* Returns the pod name once logs can be streamed, or throws on failure.
|
||||
@@ -52,14 +150,22 @@ async function waitForPod(namespace, jobName, timeoutMs, onLog, kubeconfigPath)
|
||||
details.push(`${cs.name}: waiting (${cs.state.waiting.reason ?? "unknown"})`);
|
||||
else if (cs.state?.running)
|
||||
details.push(`${cs.name}: running`);
|
||||
else if (cs.state?.terminated)
|
||||
details.push(`${cs.name}: terminated (exit ${cs.state.terminated.exitCode ?? "?"}, ${cs.state.terminated.reason ?? "no reason"})`);
|
||||
}
|
||||
await onLog("stdout", `[paperclip] Pod ${podName}: ${details.join(", ")}\n`);
|
||||
lastStatus = statusKey;
|
||||
}
|
||||
// Ready to stream logs
|
||||
if (phase === "Running" || phase === "Succeeded" || phase === "Failed") {
|
||||
if (phase === "Running" || phase === "Succeeded") {
|
||||
return podName;
|
||||
}
|
||||
// phase=Failed means the pod crashed before we could stream logs.
|
||||
// Throwing here routes the caller into the error path with a structured
|
||||
// message instead of entering the log-streaming path with a dead pod.
|
||||
if (phase === "Failed") {
|
||||
throw new Error(describePodTerminatedError(podName, phase, containerStatuses));
|
||||
}
|
||||
// Init containers done + main running (phase may still say Pending briefly)
|
||||
const allInitsDone = initStatuses.length > 0 && initStatuses.every((s) => s.state?.terminated?.exitCode === 0);
|
||||
const mainRunning = containerStatuses.some((s) => s.state?.running);
|
||||
@@ -104,16 +210,32 @@ async function waitForPod(namespace, jobName, timeoutMs, onLog, kubeconfigPath)
|
||||
* Stream pod logs once via follow. Returns accumulated stdout when the
|
||||
* stream ends (container exit, API disconnect, or abort signal).
|
||||
*/
|
||||
async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds) {
|
||||
async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds, dedup, stopSignal) {
|
||||
const logApi = getLogApi(kubeconfigPath);
|
||||
const chunks = [];
|
||||
const writable = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
const text = chunk.toString("utf-8");
|
||||
chunks.push(text);
|
||||
void onLog("stdout", text).then(() => callback(), callback);
|
||||
const emitted = dedup ? dedup.filter(text) : text;
|
||||
if (!emitted) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
void onLog("stdout", emitted).then(() => callback(), callback);
|
||||
},
|
||||
});
|
||||
// When the job completion signal fires, destroy the writable to abort the
|
||||
// in-flight follow stream. Without this, logApi.log can hang indefinitely
|
||||
// when the pod terminates without closing the HTTP connection cleanly.
|
||||
let stopPoller = null;
|
||||
if (stopSignal) {
|
||||
stopPoller = setInterval(() => {
|
||||
if (stopSignal.stopped && !writable.destroyed) {
|
||||
writable.destroy();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
try {
|
||||
await logApi.log(namespace, podName, "claude", writable, {
|
||||
follow: true,
|
||||
@@ -122,8 +244,12 @@ async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinc
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// follow may fail if the container already exited or the API
|
||||
// connection dropped — not fatal, caller decides whether to retry.
|
||||
// follow may fail if the container already exited, the API connection
|
||||
// dropped, or we aborted via writable.destroy() — not fatal.
|
||||
}
|
||||
finally {
|
||||
if (stopPoller)
|
||||
clearInterval(stopPoller);
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
@@ -143,6 +269,9 @@ async function streamPodLogs(namespace, podName, onLog, kubeconfigPath, stopSign
|
||||
// reconnects use a tight window instead of an ever-growing one anchored
|
||||
// at stream start. This is the primary fix for FAR-105 duplicative logs.
|
||||
let lastLogReceivedAt = Math.floor(Date.now() / 1000);
|
||||
// Shared across reconnects so replayed lines inside the `sinceSeconds`
|
||||
// overlap window are dropped before they reach the streaming UI (FAR-123).
|
||||
const dedup = new LogLineDedupFilter();
|
||||
while (!stopSignal?.stopped) {
|
||||
if (attempt >= MAX_LOG_RECONNECT_ATTEMPTS) {
|
||||
await onLog("stderr", `[paperclip] Log stream: max reconnect attempts (${MAX_LOG_RECONNECT_ATTEMPTS}) reached — giving up.\n`);
|
||||
@@ -158,7 +287,7 @@ async function streamPodLogs(namespace, podName, onLog, kubeconfigPath, stopSign
|
||||
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt}/${MAX_LOG_RECONNECT_ATTEMPTS})...\n`);
|
||||
}
|
||||
const preStreamTs = Math.floor(Date.now() / 1000);
|
||||
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds);
|
||||
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds, dedup, stopSignal);
|
||||
if (result) {
|
||||
allChunks.push(result);
|
||||
// Update last-received timestamp to now (the stream just ended,
|
||||
@@ -177,6 +306,11 @@ async function streamPodLogs(namespace, podName, onLog, kubeconfigPath, stopSign
|
||||
// Brief pause before reconnecting to avoid tight loops.
|
||||
await new Promise((resolve) => setTimeout(resolve, LOG_STREAM_RECONNECT_DELAY_MS));
|
||||
}
|
||||
// Flush any buffered partial line so the final assistant/result chunk
|
||||
// isn't dropped when the stream ends mid-line.
|
||||
const tail = dedup.flush();
|
||||
if (tail)
|
||||
await onLog("stdout", tail);
|
||||
return allChunks.join("");
|
||||
}
|
||||
/**
|
||||
@@ -199,13 +333,27 @@ async function readPodLogs(namespace, podName, kubeconfigPath) {
|
||||
}
|
||||
/**
|
||||
* Wait for the Job to reach a terminal state (Complete or Failed).
|
||||
* Returns the Job's final status.
|
||||
* Returns the Job's final status. A 404 (job deleted by TTL or externally)
|
||||
* is treated as a soft terminal: succeeded=false, timedOut=false, jobGone=true.
|
||||
* The caller should log this and fall through to stdout parsing.
|
||||
*/
|
||||
async function waitForJobCompletion(namespace, jobName, timeoutMs, kubeconfigPath) {
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
|
||||
while (deadline === 0 || Date.now() < deadline) {
|
||||
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
let job;
|
||||
try {
|
||||
job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
}
|
||||
catch (err) {
|
||||
if (isK8s404(err)) {
|
||||
// Job was deleted (TTL garbage collection or external deletion) before
|
||||
// we detected its terminal condition. The container must have already
|
||||
// exited for TTL to fire, so log streaming will have captured the output.
|
||||
return { succeeded: false, timedOut: false, jobGone: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const conditions = job.status?.conditions ?? [];
|
||||
const complete = conditions.find((c) => c.type === "Complete" && c.status === "True");
|
||||
if (complete)
|
||||
@@ -261,10 +409,18 @@ export async function execute(ctx) {
|
||||
// Guard: claude_k8s must not run concurrently for the same agent (shared PVC/session).
|
||||
// After a server restart, orphaned K8s Jobs from previous (now-failed) runs may
|
||||
// still be running. We detect those by comparing the Job's run-id label against
|
||||
// the current runId and clean them up so this execution can proceed.
|
||||
// the current runId. When reattachOrphanedJobs is enabled and the orphan matches
|
||||
// the current agent+task+session, we attach to it instead of deleting it (FAR-124).
|
||||
const agentId = ctx.agent.id;
|
||||
const selfPod = await getSelfPodInfo(kubeconfigPath);
|
||||
const guardNamespace = asString(config.namespace, "") || selfPod.namespace;
|
||||
const reattachOrphanedJobs = asBoolean(config.reattachOrphanedJobs, true);
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const currentSessionIdRaw = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const currentSessionLabel = currentSessionIdRaw ? sanitizeLabelValue(currentSessionIdRaw) : null;
|
||||
const currentTaskIdRaw = asString(ctx.context.taskId, "") || asString(ctx.context.issueId, "");
|
||||
const currentTaskLabel = currentTaskIdRaw ? sanitizeLabelValue(currentTaskIdRaw) : null;
|
||||
let reattachTarget = null;
|
||||
try {
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
const existing = await batchApi.listNamespacedJob({
|
||||
@@ -277,10 +433,37 @@ export async function execute(ctx) {
|
||||
// concurrent jobs (same runId — shouldn't happen but guard defensively).
|
||||
const orphaned = running.filter((j) => (j.metadata?.labels?.["paperclip.io/run-id"] ?? "") !== runId);
|
||||
const samRun = running.filter((j) => (j.metadata?.labels?.["paperclip.io/run-id"] ?? "") === runId);
|
||||
if (orphaned.length > 0) {
|
||||
const orphanNames = orphaned.map((j) => j.metadata?.name).join(", ");
|
||||
await onLog("stdout", `[paperclip] Cleaning up ${orphaned.length} orphaned K8s Job(s) from previous run(s): ${orphanNames}\n`);
|
||||
for (const j of orphaned) {
|
||||
// Pick the most recent reattachable orphan — same agent + task + session,
|
||||
// not terminal. Only one target is chosen; any other orphans get
|
||||
// cleaned up as before.
|
||||
if (reattachOrphanedJobs && orphaned.length > 0) {
|
||||
const candidates = orphaned
|
||||
.filter((j) => isReattachableOrphan(j, {
|
||||
agentId,
|
||||
taskId: currentTaskLabel,
|
||||
sessionId: currentSessionLabel,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const at = new Date(a.metadata?.creationTimestamp ?? 0).getTime();
|
||||
const bt = new Date(b.metadata?.creationTimestamp ?? 0).getTime();
|
||||
return bt - at;
|
||||
});
|
||||
const chosen = candidates[0];
|
||||
const chosenName = chosen?.metadata?.name;
|
||||
if (chosen && chosenName) {
|
||||
reattachTarget = {
|
||||
jobName: chosenName,
|
||||
namespace: chosen.metadata?.namespace ?? guardNamespace,
|
||||
priorRunId: chosen.metadata?.labels?.["paperclip.io/run-id"] ?? "",
|
||||
image: chosen.spec?.template?.spec?.containers?.[0]?.image ?? "unknown",
|
||||
};
|
||||
}
|
||||
}
|
||||
const toDelete = orphaned.filter((j) => !reattachTarget || j.metadata?.name !== reattachTarget.jobName);
|
||||
if (toDelete.length > 0) {
|
||||
const orphanNames = toDelete.map((j) => j.metadata?.name).join(", ");
|
||||
await onLog("stdout", `[paperclip] Cleaning up ${toDelete.length} orphaned K8s Job(s) from previous run(s): ${orphanNames}\n`);
|
||||
for (const j of toDelete) {
|
||||
const name = j.metadata?.name;
|
||||
if (name) {
|
||||
await cleanupJob(guardNamespace, name, onLog, kubeconfigPath);
|
||||
@@ -317,81 +500,114 @@ export async function execute(ctx) {
|
||||
errorCode: "k8s_concurrency_guard_unreachable",
|
||||
};
|
||||
}
|
||||
// Build Job manifest
|
||||
const { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret } = buildJobManifest({
|
||||
ctx,
|
||||
selfPod,
|
||||
});
|
||||
// Report invocation metadata
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_k8s",
|
||||
command: `kubectl job/${jobName}`,
|
||||
cwd: namespace,
|
||||
commandArgs: claudeArgs,
|
||||
commandNotes: [
|
||||
`Image: ${job.spec?.template.spec?.containers[0]?.image ?? "unknown"}`,
|
||||
`Namespace: ${namespace}`,
|
||||
`Timeout: ${timeoutSec}s`,
|
||||
],
|
||||
prompt,
|
||||
...(promptMetrics ? { promptMetrics } : {}),
|
||||
context: ctx.context,
|
||||
});
|
||||
}
|
||||
// If the prompt is large, create a Secret to hold it (avoids the ~1 MiB
|
||||
// PodSpec limit). The Secret is cleaned up in the finally block.
|
||||
const coreApi = getCoreApi(kubeconfigPath);
|
||||
if (promptSecret) {
|
||||
try {
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace: promptSecret.namespace,
|
||||
body: {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: promptSecret.name,
|
||||
namespace: promptSecret.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/managed-by": "paperclip",
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
"paperclip.io/run-id": runId,
|
||||
},
|
||||
},
|
||||
stringData: promptSecret.data,
|
||||
},
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
let jobName;
|
||||
let namespace;
|
||||
let promptSecret = null;
|
||||
if (reattachTarget) {
|
||||
jobName = reattachTarget.jobName;
|
||||
namespace = reattachTarget.namespace;
|
||||
// Announce reattach metadata. Prompt and args aren't known here — they
|
||||
// belong to the prior run that created this pod and are already present
|
||||
// on the running container.
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_k8s",
|
||||
command: `kubectl job/${jobName}`,
|
||||
cwd: namespace,
|
||||
commandArgs: [],
|
||||
commandNotes: [
|
||||
`Image: ${reattachTarget.image}`,
|
||||
`Namespace: ${namespace}`,
|
||||
`Reattached from prior run: ${reattachTarget.priorRunId || "unknown"}`,
|
||||
`Timeout: ${timeoutSec}s`,
|
||||
],
|
||||
prompt: "",
|
||||
context: ctx.context,
|
||||
});
|
||||
await onLog("stdout", `[paperclip] Created prompt Secret: ${promptSecret.name} (${Math.round(Buffer.byteLength(prompt, "utf-8") / 1024)} KiB)\n`);
|
||||
}
|
||||
await onLog("stdout", `[paperclip] Reattaching to in-flight K8s Job ${jobName} in namespace ${namespace} (prior run ${reattachTarget.priorRunId || "unknown"})\n`);
|
||||
}
|
||||
else {
|
||||
// Build Job manifest
|
||||
const built = buildJobManifest({ ctx, selfPod });
|
||||
const job = built.job;
|
||||
jobName = built.jobName;
|
||||
namespace = built.namespace;
|
||||
const prompt = built.prompt;
|
||||
const claudeArgs = built.claudeArgs;
|
||||
const promptMetrics = built.promptMetrics;
|
||||
promptSecret = built.promptSecret;
|
||||
// Report invocation metadata
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_k8s",
|
||||
command: `kubectl job/${jobName}`,
|
||||
cwd: namespace,
|
||||
commandArgs: claudeArgs,
|
||||
commandNotes: [
|
||||
`Image: ${job.spec?.template.spec?.containers[0]?.image ?? "unknown"}`,
|
||||
`Namespace: ${namespace}`,
|
||||
`Timeout: ${timeoutSec}s`,
|
||||
],
|
||||
prompt,
|
||||
...(promptMetrics ? { promptMetrics } : {}),
|
||||
context: ctx.context,
|
||||
});
|
||||
}
|
||||
// If the prompt is large, create a Secret to hold it (avoids the ~1 MiB
|
||||
// PodSpec limit). The Secret is cleaned up in the finally block.
|
||||
if (promptSecret) {
|
||||
try {
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace: promptSecret.namespace,
|
||||
body: {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: promptSecret.name,
|
||||
namespace: promptSecret.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/managed-by": "paperclip",
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
"paperclip.io/run-id": runId,
|
||||
},
|
||||
},
|
||||
stringData: promptSecret.data,
|
||||
},
|
||||
});
|
||||
await onLog("stdout", `[paperclip] Created prompt Secret: ${promptSecret.name} (${Math.round(Buffer.byteLength(prompt, "utf-8") / 1024)} KiB)\n`);
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Failed to create prompt Secret: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Failed to create prompt Secret: ${msg}`,
|
||||
errorCode: "k8s_prompt_secret_create_failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
// Create the Job
|
||||
try {
|
||||
await batchApi.createNamespacedJob({ namespace, body: job });
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Failed to create prompt Secret: ${msg}\n`);
|
||||
await onLog("stderr", `[paperclip] Failed to create K8s Job: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Failed to create prompt Secret: ${msg}`,
|
||||
errorCode: "k8s_prompt_secret_create_failed",
|
||||
errorMessage: `Failed to create Kubernetes Job: ${msg}`,
|
||||
errorCode: "k8s_job_create_failed",
|
||||
};
|
||||
}
|
||||
await onLog("stdout", `[paperclip] Created K8s Job: ${jobName} in namespace ${namespace} (deadline: ${timeoutSec > 0 ? `${timeoutSec}s` : "none"})\n`);
|
||||
}
|
||||
// Create the Job
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
try {
|
||||
await batchApi.createNamespacedJob({ namespace, body: job });
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Failed to create K8s Job: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Failed to create Kubernetes Job: ${msg}`,
|
||||
errorCode: "k8s_job_create_failed",
|
||||
};
|
||||
}
|
||||
await onLog("stdout", `[paperclip] Created K8s Job: ${jobName} in namespace ${namespace} (deadline: ${timeoutSec > 0 ? `${timeoutSec}s` : "none"})\n`);
|
||||
let stdout = "";
|
||||
let exitCode = null;
|
||||
let jobTimedOut = false;
|
||||
@@ -404,8 +620,24 @@ export async function execute(ctx) {
|
||||
const scheduleTimeoutMs = 120_000; // 2 minutes for scheduling
|
||||
let podName;
|
||||
try {
|
||||
podName = await waitForPod(namespace, jobName, scheduleTimeoutMs, onLog, kubeconfigPath);
|
||||
await onLog("stdout", `[paperclip] Pod running: ${podName}\n`);
|
||||
if (reattachTarget) {
|
||||
// Pod is already running from the prior run — look it up directly.
|
||||
const podList = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `job-name=${jobName}`,
|
||||
});
|
||||
const pod = podList.items[0];
|
||||
const name = pod?.metadata?.name;
|
||||
if (!name) {
|
||||
throw new Error(`Reattach target Job ${jobName} has no pod`);
|
||||
}
|
||||
podName = name;
|
||||
await onLog("stdout", `[paperclip] Reattached to pod ${podName}\n`);
|
||||
}
|
||||
else {
|
||||
podName = await waitForPod(namespace, jobName, scheduleTimeoutMs, onLog, kubeconfigPath);
|
||||
await onLog("stdout", `[paperclip] Pod running: ${podName}\n`);
|
||||
}
|
||||
// Notify the server that execution has started. This sets
|
||||
// processStartedAt and refreshes updatedAt in the DB, which the
|
||||
// stale-run reaper (reapOrphanedRuns) uses to decide liveness.
|
||||
@@ -419,13 +651,14 @@ export async function execute(ctx) {
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Pod scheduling failed: ${msg}\n`);
|
||||
const phase = reattachTarget ? "reattach" : "scheduling";
|
||||
await onLog("stderr", `[paperclip] Pod ${phase} failed: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Pod scheduling failed: ${msg}`,
|
||||
errorCode: "k8s_pod_schedule_failed",
|
||||
errorMessage: `Pod ${phase} failed: ${msg}`,
|
||||
errorCode: reattachTarget ? "k8s_pod_reattach_failed" : "k8s_pod_schedule_failed",
|
||||
};
|
||||
}
|
||||
// Stream logs and wait for completion concurrently.
|
||||
@@ -457,18 +690,32 @@ export async function execute(ctx) {
|
||||
let lastLogAt = Date.now();
|
||||
let keepaliveTick = 0;
|
||||
let keepaliveJobTerminal = false;
|
||||
let keepaliveJobTerminalAt = null;
|
||||
keepaliveTimer = setInterval(() => {
|
||||
// Fire-and-forget the async work; setInterval callbacks must be
|
||||
// synchronous or the timer will drift.
|
||||
void (async () => {
|
||||
if (keepaliveJobTerminal)
|
||||
if (keepaliveJobTerminal) {
|
||||
// Post-terminal window: keep refreshing onSpawn during cleanup
|
||||
// (job deletion, log parsing, K8s API calls) so the reaper doesn't
|
||||
// fire a false process_lost while execute() is still running.
|
||||
if (ctx.onSpawn &&
|
||||
keepaliveJobTerminalAt !== null &&
|
||||
Date.now() - keepaliveJobTerminalAt <= POST_TERMINAL_KEEPALIVE_MS) {
|
||||
keepaliveTick++;
|
||||
if (keepaliveTick % 6 === 0) {
|
||||
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => { });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Verify the Job is still alive before announcing or refreshing.
|
||||
try {
|
||||
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
const terminal = job.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True");
|
||||
if (terminal) {
|
||||
keepaliveJobTerminal = true;
|
||||
keepaliveJobTerminalAt = Date.now();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -477,10 +724,9 @@ export async function execute(ctx) {
|
||||
// connection resets should NOT permanently disable the keepalive —
|
||||
// the next tick will re-check and the reaper uses the staleness
|
||||
// window as a safety net.
|
||||
const statusCode = err?.response?.statusCode
|
||||
?? err?.statusCode;
|
||||
if (statusCode === 404) {
|
||||
if (isK8s404(err)) {
|
||||
keepaliveJobTerminal = true;
|
||||
keepaliveJobTerminalAt = Date.now();
|
||||
return;
|
||||
}
|
||||
// Log transient errors but leave keepaliveJobTerminal false so
|
||||
@@ -525,23 +771,44 @@ export async function execute(ctx) {
|
||||
if (logResult.status === "fulfilled") {
|
||||
stdout = logResult.value;
|
||||
}
|
||||
// If the follow stream missed output (container exited quickly), do a
|
||||
// one-shot log read as fallback before the pod is cleaned up.
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (stdout.trim()) {
|
||||
// One-shot log fallback: handles two failure modes with a single read.
|
||||
// Mode 1 — empty stream: the follow stream returned nothing (fast exit before connection).
|
||||
// Mode 2 — partial stream: we have some output but no result event (follow stream raced
|
||||
// with container exit and captured only the init line before the connection dropped).
|
||||
// A one-shot readPodLogs is more reliable for already-terminated containers and reads
|
||||
// from the beginning of the log, giving us the full output.
|
||||
// We use a cheap string scan for the result-event guard (avoids a full JSON parse here;
|
||||
// the authoritative parse happens once below after all fallbacks complete).
|
||||
const hasResultEvent = stdout.includes('"type":"result"');
|
||||
const needsOneShot = !stdout.trim() || (stdout.trim() && !hasResultEvent);
|
||||
if (needsOneShot) {
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
}
|
||||
const oneShotLogs = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (!stdout.trim() && oneShotLogs.trim()) {
|
||||
stdout = oneShotLogs;
|
||||
await onLog("stdout", stdout);
|
||||
}
|
||||
else if (oneShotLogs && oneShotLogs.length > stdout.length) {
|
||||
await onLog("stdout", `[paperclip] Log stream captured partial output — supplemental one-shot read returned more content.\n`);
|
||||
stdout = oneShotLogs;
|
||||
}
|
||||
}
|
||||
if (completionResult.status === "fulfilled") {
|
||||
jobTimedOut = completionResult.value.timedOut;
|
||||
if (completionResult.value.jobGone) {
|
||||
// Job was deleted by TTL or externally before we observed the Complete/Failed
|
||||
// condition. The container must have exited first (TTL only fires after
|
||||
// completion), so log streaming has captured the full output — continue
|
||||
// to stdout parsing rather than returning an error.
|
||||
await onLog("stdout", `[paperclip] Job ${jobName} was deleted before terminal condition was observed (TTL or external deletion) — proceeding with captured output.\n`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// waitForJobCompletion threw — re-check job state to avoid returning
|
||||
// while the job is still running (which would cause UI staleness and
|
||||
// concurrency errors on retry). Use a bounded timeout (60s) so we
|
||||
// don't hang the heartbeat indefinitely if the K8s API is degraded.
|
||||
// waitForJobCompletion threw an unexpected error — re-check job state to
|
||||
// avoid returning while the job is still running. Use a bounded timeout
|
||||
// (60s) so we don't hang the heartbeat indefinitely if the K8s API is degraded.
|
||||
jobTimedOut = false;
|
||||
const RECHECK_TIMEOUT_MS = 60_000;
|
||||
const actualState = await waitForJobCompletion(namespace, jobName, RECHECK_TIMEOUT_MS, kubeconfigPath);
|
||||
@@ -550,6 +817,11 @@ export async function execute(ctx) {
|
||||
// Return an error so the UI knows the run is not done.
|
||||
jobTimedOut = true;
|
||||
}
|
||||
else if (actualState.jobGone) {
|
||||
// Job was deleted before we could confirm terminal state — same as the
|
||||
// fulfilled+jobGone case above: proceed with captured output.
|
||||
await onLog("stdout", `[paperclip] Job ${jobName} was deleted before terminal condition was observed (TTL or external deletion) — proceeding with captured output.\n`);
|
||||
}
|
||||
else if (!actualState.succeeded) {
|
||||
// Job still not terminal — the completion error was likely transient.
|
||||
// Return an error so the UI knows the run is not done, rather than
|
||||
@@ -615,16 +887,11 @@ export async function execute(ctx) {
|
||||
};
|
||||
}
|
||||
if (!parsed) {
|
||||
const stderrLine = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
||||
return {
|
||||
exitCode,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: exitCode === 0
|
||||
? "Failed to parse Claude JSON output"
|
||||
: stderrLine
|
||||
? `Claude exited with code ${exitCode ?? -1}: ${stderrLine}`
|
||||
: `Claude exited with code ${exitCode ?? -1}`,
|
||||
errorMessage: buildPartialRunError(exitCode, parsedStream.model, stdout),
|
||||
resultJson: { stdout },
|
||||
};
|
||||
}
|
||||
@@ -636,8 +903,7 @@ export async function execute(ctx) {
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
})();
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const fallbackSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const fallbackSessionId = currentSessionIdRaw;
|
||||
const resolvedSessionId = parsedStream.sessionId
|
||||
?? (asString(parsed.session_id, fallbackSessionId) || fallbackSessionId);
|
||||
const model = asString(config.model, "");
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+20
@@ -1,5 +1,19 @@
|
||||
import type * as k8s from "@kubernetes/client-node";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
/**
|
||||
* Build the shell command prefix that installs a native Node.js PostToolUse
|
||||
* hook into Claude Code's settings. The hook truncates oversized tool outputs
|
||||
* before they reach the model — replacing the RTK binary init-container
|
||||
* approach with a self-contained Node.js implementation.
|
||||
*
|
||||
* Both scripts are base64-encoded so they can be embedded in a sh -c command
|
||||
* string without any quoting or escaping issues.
|
||||
*
|
||||
* @param maxOutputBytes Byte threshold above which tool output is truncated.
|
||||
* @returns A shell command string (suitable for "&&"-chaining
|
||||
* before the claude invocation).
|
||||
*/
|
||||
export declare function buildRtkSetupCommands(maxOutputBytes: number): string;
|
||||
import type { SelfPodInfo } from "./k8s-client.js";
|
||||
export interface JobBuildInput {
|
||||
ctx: AdapterExecutionContext;
|
||||
@@ -24,5 +38,11 @@ export interface JobBuildResult {
|
||||
* staged as a K8s Secret before creating the Job. */
|
||||
promptSecret: PromptSecret | null;
|
||||
}
|
||||
/**
|
||||
* Sanitize a string for use as a Kubernetes label value (RFC 1123 subset:
|
||||
* `[a-zA-Z0-9]([-_.a-zA-Z0-9]*[a-zA-Z0-9])?`, max 63 chars). Returns `null`
|
||||
* when no usable characters remain — the caller should omit the label.
|
||||
*/
|
||||
export declare function sanitizeLabelValue(value: string, maxLen?: number): string | null;
|
||||
export declare function buildJobManifest(input: JobBuildInput): JobBuildResult;
|
||||
//# sourceMappingURL=job-manifest.d.ts.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAgD1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA6CnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED;;+EAE+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC;0DACsD;IACtD,YAAY,EAAE,YAAY,GAAG,IAAI,CAAC;CACnC;AAuHD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAkRrE"}
|
||||
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAY1E;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAiEpE;AAsCD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA6CnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED;;+EAE+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC;0DACsD;IACtD,YAAY,EAAE,YAAY,GAAG,IAAI,CAAC;CACnC;AAMD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,SAAK,GAAG,MAAM,GAAG,IAAI,CAI5E;AAmHD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAmSrE"}
|
||||
Vendored
+106
-1
@@ -1,5 +1,81 @@
|
||||
import { asString, asNumber, asBoolean, asStringArray, parseObject, buildPaperclipEnv, renderTemplate, } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { createHash } from "node:crypto";
|
||||
/**
|
||||
* Build the shell command prefix that installs a native Node.js PostToolUse
|
||||
* hook into Claude Code's settings. The hook truncates oversized tool outputs
|
||||
* before they reach the model — replacing the RTK binary init-container
|
||||
* approach with a self-contained Node.js implementation.
|
||||
*
|
||||
* Both scripts are base64-encoded so they can be embedded in a sh -c command
|
||||
* string without any quoting or escaping issues.
|
||||
*
|
||||
* @param maxOutputBytes Byte threshold above which tool output is truncated.
|
||||
* @returns A shell command string (suitable for "&&"-chaining
|
||||
* before the claude invocation).
|
||||
*/
|
||||
export function buildRtkSetupCommands(maxOutputBytes) {
|
||||
// --- Filter script ----------------------------------------------------------
|
||||
// This script runs as the PostToolUse hook inside every K8s Job pod.
|
||||
// Claude Code writes the hook event as JSON to the script's stdin; the script
|
||||
// truncates the tool_response/tool_result content when it exceeds the
|
||||
// threshold and writes the (possibly modified) JSON to stdout.
|
||||
//
|
||||
// Field-name coverage:
|
||||
// • tool_response — documented hook event format for PostToolUse
|
||||
// • tool_result — alternative name seen in some Claude Code versions
|
||||
// Content may be a plain string or an array of typed blocks (text/image/…).
|
||||
const filterScript = [
|
||||
`const c=[];`,
|
||||
`process.stdin.on('data',d=>c.push(d));`,
|
||||
`process.stdin.on('end',()=>{`,
|
||||
`const raw=Buffer.concat(c).toString('utf-8');`,
|
||||
`let o;try{o=JSON.parse(raw);}catch{process.stdout.write(raw);return;}`,
|
||||
`const MAX=${maxOutputBytes};`,
|
||||
`function trunc(s){`,
|
||||
`if(typeof s!=='string')return s;`,
|
||||
`const b=Buffer.from(s,'utf-8');`,
|
||||
`if(b.length<=MAX)return s;`,
|
||||
`return b.slice(0,MAX).toString('utf-8')+'\\n[...'+(b.length-MAX)+' bytes truncated by paperclip-rtk]';`,
|
||||
`}`,
|
||||
`const tr=o&&(o.tool_response||o.tool_result);`,
|
||||
`if(tr){`,
|
||||
`if(typeof tr.content==='string'){tr.content=trunc(tr.content);}`,
|
||||
`else if(Array.isArray(tr.content)){`,
|
||||
`tr.content=tr.content.map(function(b){`,
|
||||
`if(b&&typeof b==='object'&&typeof b.text==='string'){`,
|
||||
`return Object.assign({},b,{text:trunc(b.text)});`,
|
||||
`}return b;`,
|
||||
`});`,
|
||||
`}`,
|
||||
`}`,
|
||||
`process.stdout.write(JSON.stringify(o));`,
|
||||
`});`,
|
||||
].join("");
|
||||
// --- Settings script --------------------------------------------------------
|
||||
// Reads the existing ~/.claude/settings.json (if any), merges in the RTK
|
||||
// PostToolUse hook, and writes the file back. All other settings sections
|
||||
// are preserved; only PostToolUse is replaced so we own the full hook list
|
||||
// for this run.
|
||||
const settingsScript = [
|
||||
`const fs=require('fs'),pt=require('path');`,
|
||||
`const p=pt.join(process.env.HOME,'.claude','settings.json');`,
|
||||
`let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'));}catch(e){}`,
|
||||
`s.hooks=s.hooks||{};`,
|
||||
`s.hooks.PostToolUse=[{matcher:'.*',hooks:[{type:'command',command:'node /tmp/.rtk-filter.js'}]}];`,
|
||||
`fs.mkdirSync(pt.dirname(p),{recursive:true});`,
|
||||
`fs.writeFileSync(p,JSON.stringify(s));`,
|
||||
].join("");
|
||||
// Encode as base64 so the strings can be embedded directly in a shell command
|
||||
// without any quoting concerns (base64 alphabet: A-Za-z0-9+/=).
|
||||
const filterB64 = Buffer.from(filterScript, "utf-8").toString("base64");
|
||||
const settingsB64 = Buffer.from(settingsScript, "utf-8").toString("base64");
|
||||
return [
|
||||
// Write the filter script
|
||||
`node -e "require('fs').writeFileSync('/tmp/.rtk-filter.js',Buffer.from('${filterB64}','base64').toString('utf-8'))"`,
|
||||
// Install the Claude Code PostToolUse hook (merge into existing settings)
|
||||
`node -e "eval(Buffer.from('${settingsB64}','base64').toString('utf-8'))"`,
|
||||
].join(" && ");
|
||||
}
|
||||
/** Prompts above this size (bytes) are staged via a Secret instead of an
|
||||
* init container env var, protecting against the ~1 MiB PodSpec limit. */
|
||||
const LARGE_PROMPT_THRESHOLD_BYTES = 256 * 1024;
|
||||
@@ -91,6 +167,16 @@ function parseKeyValueConfig(raw) {
|
||||
function sanitizeForK8sName(value, maxLen = 16) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, maxLen);
|
||||
}
|
||||
/**
|
||||
* Sanitize a string for use as a Kubernetes label value (RFC 1123 subset:
|
||||
* `[a-zA-Z0-9]([-_.a-zA-Z0-9]*[a-zA-Z0-9])?`, max 63 chars). Returns `null`
|
||||
* when no usable characters remain — the caller should omit the label.
|
||||
*/
|
||||
export function sanitizeLabelValue(value, maxLen = 63) {
|
||||
const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, maxLen);
|
||||
const trimmed = cleaned.replace(/^[^a-zA-Z0-9]+/, "").replace(/[^a-zA-Z0-9]+$/, "");
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
/**
|
||||
* Build a short deterministic hash suffix from the raw inputs to avoid
|
||||
* collisions when sanitized slugs happen to be identical.
|
||||
@@ -202,6 +288,8 @@ export function buildJobManifest(input) {
|
||||
const nodeSelector = parseKeyValueConfig(config.nodeSelector);
|
||||
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
|
||||
const extraLabels = parseKeyValueConfig(config.labels);
|
||||
const enableRtk = asBoolean(config.enableRtk, false);
|
||||
const rtkMaxOutputBytes = asNumber(config.rtkMaxOutputBytes, 50000);
|
||||
// Resolve working directory — use workspace cwd, fall back to /paperclip
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
@@ -289,6 +377,17 @@ export function buildJobManifest(input) {
|
||||
"paperclip.io/company-id": agent.companyId,
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
};
|
||||
// Reattach-target labels: let a future execute() identify this Job as the
|
||||
// continuation of the same logical unit of work (same task + same resume
|
||||
// session) so it can attach to the running pod across a Paperclip restart
|
||||
// instead of deleting it and starting over (FAR-124).
|
||||
const taskIdRaw = asString(context.taskId, "") || asString(context.issueId, "");
|
||||
const taskLabel = taskIdRaw ? sanitizeLabelValue(taskIdRaw) : null;
|
||||
if (taskLabel)
|
||||
labels["paperclip.io/task-id"] = taskLabel;
|
||||
const sessionLabel = runtimeSessionId ? sanitizeLabelValue(runtimeSessionId) : null;
|
||||
if (sessionLabel)
|
||||
labels["paperclip.io/session-id"] = sessionLabel;
|
||||
for (const [key, value] of Object.entries(extraLabels)) {
|
||||
labels[key] = value;
|
||||
}
|
||||
@@ -345,7 +444,13 @@ export function buildJobManifest(input) {
|
||||
};
|
||||
// Build the claude command string for the main container
|
||||
const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
const mainCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
|
||||
const claudeInvocation = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
|
||||
// When RTK output filtering is enabled, prepend the Node.js hook setup.
|
||||
// This writes a filter script and a Claude Code settings file that installs
|
||||
// it as a PostToolUse hook — no external binary or init container required.
|
||||
const mainCommand = enableRtk
|
||||
? `${buildRtkSetupCommands(rtkMaxOutputBytes)} && ${claudeInvocation}`
|
||||
: claudeInvocation;
|
||||
// Decide prompt delivery strategy: env var (small) or Secret volume (large).
|
||||
const promptBytes = Buffer.byteLength(prompt, "utf-8");
|
||||
const useLargePromptPath = promptBytes > LARGE_PROMPT_THRESHOLD_BYTES;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+474
-7
@@ -1,26 +1,27 @@
|
||||
{
|
||||
"name": "paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.28",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.28",
|
||||
"version": "0.2.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
|
||||
"@paperclipai/adapter-utils": "^2026.428.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
|
||||
"@paperclipai/adapter-utils": ">=2026.428.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -117,6 +118,431 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -223,9 +649,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@paperclipai/adapter-utils": {
|
||||
"version": "2026.415.0-canary.7",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.415.0-canary.7.tgz",
|
||||
"integrity": "sha512-VNzIZmu1lrK6QM8Ad9WkOihZItfkj21NHKQf+artDcbwFT2hHbDAD9hdW2W9NMVxYdFvvnws3w76FI/BUbCMbQ==",
|
||||
"version": "2026.428.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
|
||||
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1033,6 +1459,47 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
|
||||
+9
-7
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.28",
|
||||
"version": "0.2.5",
|
||||
"description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/farhoodliquor/paperclip-adapter-claude-k8s"
|
||||
"url": "https://github.com/farhoodlabs/paperclip-adapter-claude-k8s"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/farhoodliquor/paperclip-adapter-claude-k8s/issues"
|
||||
"url": "https://github.com/farhoodlabs/paperclip-adapter-claude-k8s/issues"
|
||||
},
|
||||
"homepage": "https://github.com/farhoodliquor/paperclip-adapter-claude-k8s#readme",
|
||||
"homepage": "https://github.com/farhoodlabs/paperclip-adapter-claude-k8s#readme",
|
||||
"type": "module",
|
||||
"paperclip": {
|
||||
"adapterUiParser": "1.0.0"
|
||||
@@ -25,7 +25,8 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && npm run build:ui-parser",
|
||||
"build:ui-parser": "esbuild src/ui-parser.ts --bundle --format=cjs --target=es2020 --outfile=dist/ui-parser.js --log-level=warning",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -37,12 +38,13 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
|
||||
"@paperclipai/adapter-utils": ">=2026.428.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
|
||||
"@paperclipai/adapter-utils": "^2026.428.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { printClaudeStreamEvent } from "./format-event.js";
|
||||
import { printClaudeStreamEvent, formatClaudeStreamLine } from "./format-event.js";
|
||||
|
||||
// Mock console methods to capture output
|
||||
const consoleMock = {
|
||||
@@ -138,6 +138,39 @@ describe("printClaudeStreamEvent", () => {
|
||||
expect(output()).toBe("some output text");
|
||||
});
|
||||
|
||||
it("prints rate_limit_event with type, status, and reset time", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "rate_limit_event",
|
||||
rate_limit_info: {
|
||||
status: "allowed",
|
||||
resetsAt: 1777056000,
|
||||
rateLimitType: "five_hour",
|
||||
overageStatus: "allowed",
|
||||
isUsingOverage: false,
|
||||
},
|
||||
uuid: "3ab8f9eb-b9d6-4bf6-9c39-4608427717fc",
|
||||
session_id: "ad5f3e11-3c0c-4144-b53d-d4b959e57cee",
|
||||
}), false);
|
||||
expect(output()).toContain("rate_limit:");
|
||||
expect(output()).toContain("five_hour");
|
||||
expect(output()).toContain("allowed");
|
||||
expect(output()).toContain("resets=");
|
||||
// Raw JSON must not be surfaced verbatim
|
||||
expect(output()).not.toContain("3ab8f9eb-b9d6-4bf6-9c39-4608427717fc");
|
||||
});
|
||||
|
||||
it("prints rate_limit_event with unknown fields gracefully", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({
|
||||
type: "rate_limit_event",
|
||||
rate_limit_info: {},
|
||||
}), false);
|
||||
expect(output()).toContain("rate_limit:");
|
||||
expect(output()).toContain("type=unknown");
|
||||
expect(output()).toContain("status=unknown");
|
||||
// No resetsAt present — reset clause omitted
|
||||
expect(output()).not.toContain("resets=");
|
||||
});
|
||||
|
||||
it("does not print unknown types in non-debug mode", () => {
|
||||
printClaudeStreamEvent(JSON.stringify({ type: "unknown", data: "stuff" }), false);
|
||||
expect(output()).toBe("");
|
||||
@@ -148,3 +181,103 @@ describe("printClaudeStreamEvent", () => {
|
||||
expect(output()).toContain("stuff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatClaudeStreamLine", () => {
|
||||
it("returns null for empty/blank lines", () => {
|
||||
expect(formatClaudeStreamLine("")).toBeNull();
|
||||
expect(formatClaudeStreamLine(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns raw text for non-JSON lines (adapter status messages pass through)", () => {
|
||||
expect(formatClaudeStreamLine("[paperclip] Pod running: pod-abc")).toBe("[paperclip] Pod running: pod-abc");
|
||||
expect(formatClaudeStreamLine("Error: disk full")).toBe("Error: disk full");
|
||||
});
|
||||
|
||||
it("formats system/init event", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "system", subtype: "init", model: "claude-opus-4-7", session_id: "sess_abc",
|
||||
}));
|
||||
expect(result).toContain("Claude initialized");
|
||||
expect(result).toContain("claude-opus-4-7");
|
||||
expect(result).toContain("sess_abc");
|
||||
expect(result).not.toContain("{");
|
||||
});
|
||||
|
||||
it("formats assistant text block", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Hello world" }] },
|
||||
}));
|
||||
expect(result).toBe("assistant: Hello world");
|
||||
});
|
||||
|
||||
it("formats assistant thinking block", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "thinking", thinking: "Let me think..." }] },
|
||||
}));
|
||||
expect(result).toBe("thinking: Let me think...");
|
||||
});
|
||||
|
||||
it("formats assistant tool_use block", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] },
|
||||
}));
|
||||
expect(result).toContain("tool_call: Bash");
|
||||
expect(result).toContain("ls");
|
||||
});
|
||||
|
||||
it("returns null for assistant with no printable content (thinking-only with empty text)", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "thinking", thinking: "" }] },
|
||||
}));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("formats user tool_result", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "user",
|
||||
message: { content: [{ type: "tool_result", content: "file1.txt\nfile2.txt" }] },
|
||||
}));
|
||||
expect(result).toContain("tool_result");
|
||||
expect(result).toContain("file1.txt");
|
||||
});
|
||||
|
||||
it("formats user tool_result error", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "user",
|
||||
message: { content: [{ type: "tool_result", is_error: true, content: "Permission denied" }] },
|
||||
}));
|
||||
expect(result).toContain("tool_result (error)");
|
||||
expect(result).toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("formats result event with tokens and cost", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "result", result: "Done", subtype: "stop", total_cost_usd: 0.005,
|
||||
usage: { input_tokens: 100, output_tokens: 200, cache_read_input_tokens: 50 },
|
||||
}));
|
||||
expect(result).toContain("result:");
|
||||
expect(result).toContain("Done");
|
||||
expect(result).toContain("in=100");
|
||||
expect(result).toContain("out=200");
|
||||
expect(result).toContain("cached=50");
|
||||
});
|
||||
|
||||
it("formats rate_limit_event (FAR-32 repro)", () => {
|
||||
const result = formatClaudeStreamLine(JSON.stringify({
|
||||
type: "rate_limit_event",
|
||||
rate_limit_info: { status: "allowed", resetsAt: 1777056000, rateLimitType: "five_hour" },
|
||||
}));
|
||||
expect(result).toContain("rate_limit:");
|
||||
expect(result).toContain("five_hour");
|
||||
expect(result).toContain("allowed");
|
||||
expect(result).not.toContain("{");
|
||||
});
|
||||
|
||||
it("returns null for unknown event types", () => {
|
||||
expect(formatClaudeStreamLine(JSON.stringify({ type: "unknown_event", data: "x" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+146
-7
@@ -17,27 +17,150 @@ function asErrorText(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function printToolResult(block: Record<string, unknown>): void {
|
||||
const isError = block.is_error === true;
|
||||
let text = "";
|
||||
if (typeof block.content === "string") {
|
||||
text = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
function extractToolResultText(block: Record<string, unknown>): string {
|
||||
if (typeof block.content === "string") return block.content;
|
||||
if (Array.isArray(block.content)) {
|
||||
const parts: string[] = [];
|
||||
for (const part of block.content) {
|
||||
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
||||
const record = part as Record<string, unknown>;
|
||||
if (typeof record.text === "string") parts.push(record.text);
|
||||
}
|
||||
text = parts.join("\n");
|
||||
return parts.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function printToolResult(block: Record<string, unknown>): void {
|
||||
const isError = block.is_error === true;
|
||||
const text = extractToolResultText(block);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (text) {
|
||||
console.log((isError ? pc.red : pc.gray)(text));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single raw Claude stream-json line into a plain-text human-readable
|
||||
* string (no ANSI colour codes) suitable for forwarding to the Paperclip server
|
||||
* via onLog. Returns null for lines that should be suppressed (empty,
|
||||
* assistant events with no printable content, etc.). Non-JSON lines are
|
||||
* returned as-is so plain-text adapter status messages pass through unchanged.
|
||||
*
|
||||
* Mirrors the event coverage of printClaudeStreamEvent so the K8s server
|
||||
* streaming path and the CLI display path produce consistent output.
|
||||
*/
|
||||
export function formatClaudeStreamLine(raw: string): string | null {
|
||||
const line = raw.trim();
|
||||
if (!line) return null;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
const model = typeof parsed.model === "string" ? parsed.model : "unknown";
|
||||
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
|
||||
return `Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const lines: string[] = [];
|
||||
for (const blockRaw of content) {
|
||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) lines.push(`assistant: ${text}`);
|
||||
} else if (blockType === "thinking") {
|
||||
const text = typeof block.thinking === "string" ? block.thinking : "";
|
||||
if (text) lines.push(`thinking: ${text}`);
|
||||
} else if (blockType === "tool_use") {
|
||||
const name = typeof block.name === "string" ? block.name : "unknown";
|
||||
lines.push(`tool_call: ${name}`);
|
||||
if (block.input !== undefined) {
|
||||
lines.push(JSON.stringify(block.input, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.length > 0 ? lines.join("\n") : null;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const lines: string[] = [];
|
||||
for (const blockRaw of content) {
|
||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
if (typeof block.type === "string" && block.type === "tool_result") {
|
||||
const isError = block.is_error === true;
|
||||
const text = extractToolResultText(block);
|
||||
lines.push(`tool_result${isError ? " (error)" : ""}`);
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
}
|
||||
return lines.length > 0 ? lines.join("\n") : null;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage =
|
||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||
? (parsed.usage as Record<string, unknown>)
|
||||
: {};
|
||||
const input = Number(usage.input_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? 0);
|
||||
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
||||
const cost = Number(parsed.total_cost_usd ?? 0);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||
const isError = parsed.is_error === true;
|
||||
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
||||
const lines: string[] = [];
|
||||
if (resultText) {
|
||||
lines.push("result:");
|
||||
lines.push(resultText);
|
||||
}
|
||||
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
||||
lines.push(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`);
|
||||
if (errors.length > 0) lines.push(`claude_errors: ${errors.join(" | ")}`);
|
||||
}
|
||||
lines.push(`tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (type === "rate_limit_event") {
|
||||
const info =
|
||||
typeof parsed.rate_limit_info === "object" && parsed.rate_limit_info !== null
|
||||
? (parsed.rate_limit_info as Record<string, unknown>)
|
||||
: {};
|
||||
const limitType = typeof info.rateLimitType === "string" ? info.rateLimitType : "unknown";
|
||||
const status = typeof info.status === "string" ? info.status : "unknown";
|
||||
const resetsAt = typeof info.resetsAt === "number"
|
||||
? new Date(info.resetsAt * 1000).toISOString()
|
||||
: "";
|
||||
const parts = [`rate_limit: type=${limitType} status=${status}`];
|
||||
if (resetsAt) parts.push(`resets=${resetsAt}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
@@ -133,6 +256,22 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "rate_limit_event") {
|
||||
const info =
|
||||
typeof parsed.rate_limit_info === "object" && parsed.rate_limit_info !== null
|
||||
? (parsed.rate_limit_info as Record<string, unknown>)
|
||||
: {};
|
||||
const limitType = typeof info.rateLimitType === "string" ? info.rateLimitType : "unknown";
|
||||
const status = typeof info.status === "string" ? info.status : "unknown";
|
||||
const resetsAt = typeof info.resetsAt === "number"
|
||||
? new Date(info.resetsAt * 1000).toISOString()
|
||||
: "";
|
||||
const parts = [`rate_limit: type=${limitType} status=${status}`];
|
||||
if (resetsAt) parts.push(`resets=${resetsAt}`);
|
||||
console.log(pc.yellow(parts.join(" ")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
|
||||
+30
-2
@@ -1,7 +1,35 @@
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
|
||||
export const type = "claude_k8s";
|
||||
export const label = "Claude (Kubernetes)";
|
||||
|
||||
export const models: undefined = undefined;
|
||||
function isBedrockEnv(): boolean {
|
||||
return (
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
|
||||
(typeof process.env.ANTHROPIC_BEDROCK_BASE_URL === "string" &&
|
||||
process.env.ANTHROPIC_BEDROCK_BASE_URL.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
const DIRECT_MODELS: AdapterModel[] = [
|
||||
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
||||
];
|
||||
|
||||
const BEDROCK_MODELS: AdapterModel[] = [
|
||||
{ id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" },
|
||||
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
|
||||
{ id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
|
||||
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "Bedrock Sonnet 4.5" },
|
||||
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
|
||||
];
|
||||
|
||||
export const models = isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
|
||||
|
||||
export const agentConfigurationDoc = `# claude_k8s agent configuration
|
||||
|
||||
@@ -15,7 +43,6 @@ Core fields:
|
||||
- model (string, optional): Claude model id
|
||||
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
||||
- maxTurnsPerRun (number, optional): max turns for one run
|
||||
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file
|
||||
- extraArgs (string[], optional): additional CLI args appended to the claude command
|
||||
- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment
|
||||
@@ -31,6 +58,7 @@ Kubernetes fields:
|
||||
- labels (object, optional): extra labels added to Job metadata
|
||||
- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300
|
||||
- retainJobs (boolean, optional): skip cleanup on completion for debugging
|
||||
- reattachOrphanedJobs (boolean, optional): when true (default), attach to a running orphaned Job that matches the current agent/task/session instead of blocking; when false, any non-terminal orphan blocks the new run
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout
|
||||
|
||||
@@ -34,9 +34,15 @@ describe("getConfigSchema", () => {
|
||||
expect(field!.default).toBe(1000);
|
||||
});
|
||||
|
||||
it("dangerouslySkipPermissions defaults to true", () => {
|
||||
it("does not expose dangerouslySkipPermissions in UI schema", () => {
|
||||
const schema = getConfigSchema();
|
||||
const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "dangerouslySkipPermissions");
|
||||
expect(field).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reattachOrphanedJobs defaults to true", () => {
|
||||
const schema = getConfigSchema();
|
||||
const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "reattachOrphanedJobs");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe("toggle");
|
||||
expect(field!.default).toBe(true);
|
||||
|
||||
@@ -34,13 +34,6 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
hint: "Maximum number of agentic turns (tool calls) per heartbeat run. 0 means unlimited.",
|
||||
default: 1000,
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
key: "dangerouslySkipPermissions",
|
||||
label: "Skip Permissions",
|
||||
hint: "Pass --dangerously-skip-permissions to Claude. Enabled by default for unattended K8s Jobs.",
|
||||
default: true,
|
||||
},
|
||||
// Kubernetes
|
||||
{
|
||||
type: "text",
|
||||
@@ -89,6 +82,13 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
label: "Retain Jobs",
|
||||
hint: "Skip cleanup of completed Jobs for debugging purposes.",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
key: "reattachOrphanedJobs",
|
||||
label: "Reattach to Orphaned Jobs",
|
||||
hint: "If a prior K8s Job for the same agent/task/session is still running (e.g. Paperclip restarted mid-run), attach to it and stream its output instead of blocking the new run. When false, any non-terminal orphan blocks the new run. Default: on.",
|
||||
default: true,
|
||||
},
|
||||
// Resource Limits
|
||||
{
|
||||
type: "text",
|
||||
@@ -133,22 +133,7 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
label: "Labels",
|
||||
hint: "Extra labels added to Job metadata. One key=value per line.",
|
||||
},
|
||||
// Output filtering (RTK-compatible)
|
||||
{
|
||||
type: "toggle",
|
||||
key: "enableRtk",
|
||||
label: "Enable Output Filtering",
|
||||
hint: "Truncate oversized tool outputs before they reach the model, reducing token consumption. Implemented natively in Node.js — no external binary required. Installs a PostToolUse hook in ~/.claude/settings.json for each run.",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
key: "rtkMaxOutputBytes",
|
||||
label: "Max Tool Output Bytes",
|
||||
hint: "Maximum bytes of tool output to pass to the model when output filtering is enabled. Outputs exceeding this threshold are truncated with a summary. Default: 50000.",
|
||||
default: 50000,
|
||||
},
|
||||
];
|
||||
|
||||
return { fields };
|
||||
}
|
||||
}
|
||||
+828
-7
@@ -1,5 +1,92 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isK8s404, buildPartialRunError } from "./execute.js";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type * as k8s from "@kubernetes/client-node";
|
||||
import type { Writable } from "node:stream";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
// All K8s API mock functions — declared before vi.mock() so the factory can
|
||||
// reference them. The mock's logApi.log default is a never-resolving promise,
|
||||
// simulating the FAR-10 hang where K8s API drops the connection indefinitely.
|
||||
const mockLogFn = vi.fn();
|
||||
const mockGetSelfPodInfo = vi.fn();
|
||||
const mockBatchListJobs = vi.fn();
|
||||
const mockBatchCreateJob = vi.fn();
|
||||
const mockBatchReadJob = vi.fn();
|
||||
const mockBatchDeleteJob = vi.fn();
|
||||
const mockBatchPatchJob = vi.fn();
|
||||
const mockCoreListPods = vi.fn();
|
||||
const mockCoreReadPodLog = vi.fn();
|
||||
const mockCoreCreateSecret = vi.fn();
|
||||
const mockCorePatchSecret = vi.fn();
|
||||
const mockCoreDeleteSecret = vi.fn();
|
||||
// vi.hoisted ensures a single vi.fn() instance shared between the mock factory
|
||||
// (which runs at hoist time) and the test body (which calls mockResolvedValue).
|
||||
// A plain const would be re-assigned at its original position, leaving the
|
||||
// factory with a stale reference to a different vi.fn() instance.
|
||||
const mockReadSkillEntries = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Module-level state for fs mock - kept for future use if mock is needed
|
||||
const mockFsContent = new Map<string, string>();
|
||||
|
||||
vi.mock("./k8s-client.js", () => ({
|
||||
getLogApi: () => ({ log: mockLogFn }),
|
||||
getBatchApi: () => ({
|
||||
listNamespacedJob: mockBatchListJobs,
|
||||
createNamespacedJob: mockBatchCreateJob,
|
||||
readNamespacedJob: mockBatchReadJob,
|
||||
deleteNamespacedJob: mockBatchDeleteJob,
|
||||
patchNamespacedJob: mockBatchPatchJob,
|
||||
}),
|
||||
getCoreApi: () => ({
|
||||
listNamespacedPod: mockCoreListPods,
|
||||
readNamespacedPodLog: mockCoreReadPodLog,
|
||||
createNamespacedSecret: mockCoreCreateSecret,
|
||||
patchNamespacedSecret: mockCorePatchSecret,
|
||||
deleteNamespacedSecret: mockCoreDeleteSecret,
|
||||
}),
|
||||
getAuthzApi: () => ({}),
|
||||
getSelfPodInfo: mockGetSelfPodInfo,
|
||||
resetCache: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPrepareBundle = vi.fn();
|
||||
vi.mock("./prompt-cache.js", () => ({
|
||||
prepareClaudePromptBundle: mockPrepareBundle,
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@paperclipai/adapter-utils/server-utils")>();
|
||||
// Enumerate all original exports so transitive deps (job-manifest.ts, parse.ts,
|
||||
// prompt-cache.ts, etc.) keep working. Only readPaperclipRuntimeSkillEntries
|
||||
// is replaced so tests run without real fs.stat I/O under fake timers.
|
||||
return Object.assign(Object.create(null), original, {
|
||||
readPaperclipRuntimeSkillEntries: mockReadSkillEntries,
|
||||
});
|
||||
});
|
||||
|
||||
const { isK8s404, buildPartialRunError, classifyOrphan, describePodTerminatedError, describeTruncationCause, streamPodLogsOnce, shouldAbortForCancellation, execute } = await import("./execute.js");
|
||||
|
||||
function makeJob(opts: {
|
||||
runId?: string;
|
||||
agentId?: string;
|
||||
taskId?: string;
|
||||
sessionId?: string;
|
||||
adapterType?: string;
|
||||
terminal?: boolean;
|
||||
}): k8s.V1Job {
|
||||
const labels: Record<string, string> = {
|
||||
"paperclip.io/adapter-type": opts.adapterType ?? "claude_k8s",
|
||||
};
|
||||
if (opts.agentId) labels["paperclip.io/agent-id"] = opts.agentId;
|
||||
if (opts.runId) labels["paperclip.io/run-id"] = opts.runId;
|
||||
if (opts.taskId) labels["paperclip.io/task-id"] = opts.taskId;
|
||||
if (opts.sessionId) labels["paperclip.io/session-id"] = opts.sessionId;
|
||||
return {
|
||||
metadata: { name: "ac-job", namespace: "paperclip", labels },
|
||||
status: opts.terminal
|
||||
? { conditions: [{ type: "Complete", status: "True" }] }
|
||||
: { conditions: [] },
|
||||
} as k8s.V1Job;
|
||||
}
|
||||
|
||||
describe("isK8s404", () => {
|
||||
it("returns false for non-Error values", () => {
|
||||
@@ -66,10 +153,10 @@ describe("buildPartialRunError", () => {
|
||||
expect(buildPartialRunError(null, "", "")).toBe("Claude exited with code -1");
|
||||
});
|
||||
|
||||
it("skips system/init events and returns generic message when only init captured", () => {
|
||||
it("returns init-only message when stdout is init-only with non-zero exit code (FAR-101)", () => {
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", initLine);
|
||||
expect(msg).toBe(
|
||||
"Claude started but did not produce a result (model: claude-sonnet-4-6) — check API credentials, model support, and adapter config",
|
||||
"Claude exited immediately after init (model: claude-sonnet-4-6) (exit code 1) — the model may be unsupported or the session may have been rejected before producing output",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -86,12 +173,68 @@ describe("buildPartialRunError", () => {
|
||||
expect(msg).toBe("Claude exited with code 1: Error: no API key configured");
|
||||
});
|
||||
|
||||
it("uses first non-system JSON event as content", () => {
|
||||
it("returns init-only message when stdout has init + result event but no plain content (structured artefact, not surfaced verbatim)", () => {
|
||||
// In production, buildPartialRunError is only called when parseClaudeStreamJson
|
||||
// returns null (no result event). If somehow a result event appears here, the
|
||||
// raw JSON blob must not be shown — the init-only message is cleaner and avoids
|
||||
// leaking protocol internals to the UI.
|
||||
const resultLike = JSON.stringify({ type: "result", subtype: "error", result: "rate limit" });
|
||||
const stdout = [initLine, resultLike].join("\n");
|
||||
const msg = buildPartialRunError(2, "claude-sonnet-4-6", stdout);
|
||||
expect(msg).toContain("rate limit");
|
||||
expect(msg).toContain("code 2");
|
||||
expect(msg).toContain("Claude exited immediately after init");
|
||||
expect(msg).toContain("claude-sonnet-4-6");
|
||||
expect(msg).not.toMatch(/\{.*type.*result/);
|
||||
});
|
||||
|
||||
it("skips rate_limit_event and surfaces model hint (FAR-32 Anthropic/Nancy repro)", () => {
|
||||
// Reproduces the second variant from FAR-32: init event + rate_limit_event +
|
||||
// assistant event (thinking only, no result). The rate_limit_event JSON blob
|
||||
// must not appear verbatim in the error message.
|
||||
const rateLimitEvent = JSON.stringify({
|
||||
type: "rate_limit_event",
|
||||
rate_limit_info: { status: "allowed", resetsAt: 1777056000, rateLimitType: "five_hour" },
|
||||
uuid: "3ab8f9eb-b9d6-4bf6-9c39-4608427717fc",
|
||||
session_id: "ad5f3e11-3c0c-4144-b53d-d4b959e57cee",
|
||||
});
|
||||
const stdout = [initLine, rateLimitEvent].join("\n");
|
||||
const msg = buildPartialRunError(null, "claude-opus-4-7", stdout);
|
||||
expect(msg).toContain("claude-opus-4-7");
|
||||
expect(msg).toContain("did not produce a result");
|
||||
expect(msg).not.toContain("rate_limit_event");
|
||||
expect(msg).not.toContain("rateLimitType");
|
||||
});
|
||||
|
||||
it("skips assistant events and surfaces model hint (FAR-32: MiniMax-M2.7 output_tokens=0)", () => {
|
||||
// Reproduces the exact failure: init event + assistant event with only a
|
||||
// thinking block and output_tokens=0, no result event. The assistant JSON
|
||||
// blob must not be surfaced verbatim as the error message.
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "063ad6038e4c889faa7c95168e007d73",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Let me start…", signature: "abc123" }],
|
||||
model: "MiniMax-M2.7",
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 11013, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||
},
|
||||
});
|
||||
const stdout = [initLine, assistantEvent].join("\n");
|
||||
const msg = buildPartialRunError(null, "MiniMax-M2.7", stdout);
|
||||
expect(msg).toContain("MiniMax-M2.7");
|
||||
expect(msg).toContain("did not produce a result");
|
||||
expect(msg).not.toContain("063ad6038e4c889faa7c95168e007d73");
|
||||
expect(msg).not.toContain("output_tokens");
|
||||
expect(msg).not.toContain("thinking");
|
||||
});
|
||||
|
||||
it("skips user events alongside system events", () => {
|
||||
const userEvent = JSON.stringify({ type: "user", message: { role: "user", content: [] } });
|
||||
const stdout = [initLine, userEvent, "Error: API quota exceeded"].join("\n");
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", stdout);
|
||||
expect(msg).toBe("Claude exited with code 1: Error: API quota exceeded");
|
||||
});
|
||||
|
||||
it("null exitCode renders as -1 in message", () => {
|
||||
@@ -105,4 +248,682 @@ describe("buildPartialRunError", () => {
|
||||
const msg = buildPartialRunError(1, "model-x", stdout);
|
||||
expect(msg).toBe("Claude exited with code 1: real error line");
|
||||
});
|
||||
|
||||
it("appends pod terminated reason/message when state is provided (FAR-100)", () => {
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", initLine, {
|
||||
exitCode: 1,
|
||||
reason: "Error",
|
||||
message: "model not supported",
|
||||
signal: null,
|
||||
});
|
||||
expect(msg).toContain("Claude exited immediately after init");
|
||||
expect(msg).toContain("claude-sonnet-4-6");
|
||||
expect(msg).toContain("[pod: reason=Error, message=model not supported]");
|
||||
});
|
||||
|
||||
it("flags exit 137 as OOMKilled in pod cause", () => {
|
||||
const msg = buildPartialRunError(137, "claude-sonnet-4-6", initLine, {
|
||||
exitCode: 137,
|
||||
reason: "OOMKilled",
|
||||
message: null,
|
||||
signal: null,
|
||||
});
|
||||
expect(msg).toContain("[pod: reason=OOMKilled, SIGKILL (commonly OOMKilled)]");
|
||||
});
|
||||
|
||||
it("appends pod cause to content-line message", () => {
|
||||
const stdout = [initLine, "Error: bad request"].join("\n");
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", stdout, {
|
||||
exitCode: 1,
|
||||
reason: "Error",
|
||||
message: null,
|
||||
signal: null,
|
||||
});
|
||||
expect(msg).toBe("Claude exited with code 1: Error: bad request [pod: reason=Error]");
|
||||
});
|
||||
|
||||
it("does not append anything when podState is null (back-compat)", () => {
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", initLine, null);
|
||||
expect(msg).not.toContain("[pod:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyOrphan", () => {
|
||||
const taskId = "task-xyz";
|
||||
const sessionId = "sess-123";
|
||||
|
||||
// --- Happy path: reattach ---
|
||||
it("returns reattach when taskId matches and both sessionIds match", () => {
|
||||
const job = makeJob({ taskId, sessionId });
|
||||
expect(classifyOrphan(job, { taskId, sessionId })).toBe("reattach");
|
||||
});
|
||||
|
||||
it("returns reattach when taskId matches and expected sessionId is null (missing on current side)", () => {
|
||||
const job = makeJob({ taskId, sessionId });
|
||||
expect(classifyOrphan(job, { taskId, sessionId: null })).toBe("reattach");
|
||||
});
|
||||
|
||||
it("returns reattach when taskId matches and job has no session-id label (missing on job side)", () => {
|
||||
const job = makeJob({ taskId });
|
||||
expect(classifyOrphan(job, { taskId, sessionId })).toBe("reattach");
|
||||
});
|
||||
|
||||
it("returns reattach when taskId matches and neither side has a sessionId", () => {
|
||||
const job = makeJob({ taskId });
|
||||
expect(classifyOrphan(job, { taskId, sessionId: null })).toBe("reattach");
|
||||
});
|
||||
|
||||
// --- Block: task unknown ---
|
||||
it("returns block_task_unknown when expected taskId is null", () => {
|
||||
const job = makeJob({ taskId, sessionId });
|
||||
expect(classifyOrphan(job, { taskId: null, sessionId })).toBe("block_task_unknown");
|
||||
});
|
||||
|
||||
it("returns block_task_unknown when job has no task-id label", () => {
|
||||
const job = makeJob({ sessionId });
|
||||
expect(classifyOrphan(job, { taskId, sessionId })).toBe("block_task_unknown");
|
||||
});
|
||||
|
||||
// --- Block: task mismatch ---
|
||||
it("returns block_task_mismatch when both sides have taskId but they differ", () => {
|
||||
const job = makeJob({ taskId: "task-other", sessionId });
|
||||
expect(classifyOrphan(job, { taskId, sessionId })).toBe("block_task_mismatch");
|
||||
});
|
||||
|
||||
// --- Block: session mismatch ---
|
||||
it("returns block_session_mismatch when taskId matches but sessionIds differ", () => {
|
||||
const job = makeJob({ taskId, sessionId: "sess-other" });
|
||||
expect(classifyOrphan(job, { taskId, sessionId })).toBe("block_session_mismatch");
|
||||
});
|
||||
|
||||
// --- Terminal orphan (caller filters these before classifyOrphan) ---
|
||||
it("returns reattach for terminal job (caller is responsible for filtering terminals)", () => {
|
||||
const job = makeJob({ taskId, sessionId, terminal: true });
|
||||
// classifyOrphan does not check terminal status — that is the caller's job
|
||||
expect(classifyOrphan(job, { taskId, sessionId })).toBe("reattach");
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: FAR-10 — waitForPod must throw on phase=Failed, not return the pod name.
|
||||
// These tests cover describePodTerminatedError, the helper that waitForPod uses to build
|
||||
// the error message before throwing. Verifies that phase=Failed with no claude logs
|
||||
// produces a structured, actionable error instead of silently entering the log-stream path.
|
||||
describe("describePodTerminatedError", () => {
|
||||
it("includes exit code and reason when claude container status is available", () => {
|
||||
const cs = [
|
||||
{
|
||||
name: "claude",
|
||||
state: { terminated: { exitCode: 137, reason: "OOMKilled" } },
|
||||
},
|
||||
] as k8s.V1ContainerStatus[];
|
||||
const msg = describePodTerminatedError("mypod", "Failed", cs);
|
||||
expect(msg).toContain("137");
|
||||
expect(msg).toContain("OOMKilled");
|
||||
expect(msg).toContain("phase=Failed");
|
||||
});
|
||||
|
||||
it("falls back to message field when reason is absent", () => {
|
||||
const cs = [
|
||||
{
|
||||
name: "claude",
|
||||
state: { terminated: { exitCode: 1, message: "signal: killed" } },
|
||||
},
|
||||
] as k8s.V1ContainerStatus[];
|
||||
const msg = describePodTerminatedError("mypod", "Failed", cs);
|
||||
expect(msg).toContain("signal: killed");
|
||||
expect(msg).toContain("1");
|
||||
});
|
||||
|
||||
it("returns generic message when no claude container status is present", () => {
|
||||
const msg = describePodTerminatedError("mypod", "Failed", []);
|
||||
expect(msg).toBe("Pod mypod reached phase=Failed");
|
||||
});
|
||||
|
||||
it("ignores non-claude containers", () => {
|
||||
const cs = [
|
||||
{
|
||||
name: "sidecar",
|
||||
state: { terminated: { exitCode: 0, reason: "Completed" } },
|
||||
},
|
||||
] as k8s.V1ContainerStatus[];
|
||||
const msg = describePodTerminatedError("mypod", "Failed", cs);
|
||||
expect(msg).toBe("Pod mypod reached phase=Failed");
|
||||
});
|
||||
|
||||
it("handles null exitCode gracefully", () => {
|
||||
const cs = [
|
||||
{
|
||||
name: "claude",
|
||||
state: { terminated: { exitCode: null, reason: "Error" } },
|
||||
},
|
||||
] as unknown as k8s.V1ContainerStatus[];
|
||||
const msg = describePodTerminatedError("mypod", "Failed", cs);
|
||||
expect(msg).toContain("unknown");
|
||||
expect(msg).toContain("Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeTruncationCause", () => {
|
||||
it("annotates exit code 137 as SIGKILL/OOM", () => {
|
||||
const msg = describeTruncationCause({ exitCode: 137, reason: "OOMKilled", message: "Memory cgroup out of memory", signal: null });
|
||||
expect(msg).toContain("exit code 137");
|
||||
expect(msg).toContain("SIGKILL");
|
||||
expect(msg).toContain("OOMKilled");
|
||||
expect(msg).toContain("Memory cgroup out of memory");
|
||||
});
|
||||
|
||||
it("annotates exit code 143 as SIGTERM", () => {
|
||||
const msg = describeTruncationCause({ exitCode: 143, reason: null, message: null, signal: null });
|
||||
expect(msg).toContain("exit code 143");
|
||||
expect(msg).toContain("SIGTERM");
|
||||
});
|
||||
|
||||
it("falls back to 'pod state unavailable' when state is null", () => {
|
||||
const msg = describeTruncationCause(null);
|
||||
expect(msg).toContain("pod state unavailable");
|
||||
});
|
||||
|
||||
it("emits 'no exit code' when exitCode is null but state exists", () => {
|
||||
const msg = describeTruncationCause({ exitCode: null, reason: "Error", message: null, signal: null });
|
||||
expect(msg).toContain("no exit code");
|
||||
expect(msg).toContain("reason=Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute: all-invalid agent.id (N4)", () => {
|
||||
it("returns hard error without creating a Job when agent.id sanitizes to null", async () => {
|
||||
const logs: string[] = [];
|
||||
const result = await execute({
|
||||
runId: "run-001",
|
||||
agent: { id: "@@@", companyId: "co1", name: "Bad Agent", adapterType: "claude_k8s", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: async (_stream, msg) => { logs.push(msg); },
|
||||
});
|
||||
expect(result.errorCode).toBe("k8s_agent_id_invalid");
|
||||
expect(result.errorMessage).toContain("@@@");
|
||||
// getSelfPodInfo must NOT have been called (early return before K8s calls)
|
||||
const { getSelfPodInfo } = await import("./k8s-client.js");
|
||||
expect(getSelfPodInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ─── Helpers shared across execute() integration tests ───────────────────────
|
||||
|
||||
function makeCtx(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
|
||||
return {
|
||||
runId: "run-test-001",
|
||||
agent: {
|
||||
id: "agent-abc",
|
||||
companyId: "co1",
|
||||
name: "Test Agent",
|
||||
adapterType: "claude_k8s",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {},
|
||||
context: {},
|
||||
onLog: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as AdapterExecutionContext;
|
||||
}
|
||||
|
||||
function makeSelfPodResult() {
|
||||
return {
|
||||
namespace: "paperclip",
|
||||
image: "paperclipai/paperclip:latest",
|
||||
imagePullSecrets: [],
|
||||
dnsConfig: undefined,
|
||||
pvcClaimName: "paperclip-data",
|
||||
secretVolumes: [],
|
||||
inheritedEnv: {},
|
||||
inheritedEnvValueFrom: [],
|
||||
inheritedEnvFrom: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeBundle() {
|
||||
return {
|
||||
bundleKey: "test-bundle",
|
||||
rootDir: "/tmp/test-bundle",
|
||||
addDir: "/tmp/test-bundle",
|
||||
instructionsFilePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Valid minimal Claude stream-json output used in happy-path tests.
|
||||
const CLAUDE_HAPPY_OUTPUT = [
|
||||
JSON.stringify({ type: "system", subtype: "init", model: "claude-sonnet-4-6", session_id: "sess_test123" }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done.",
|
||||
session_id: "sess_test123",
|
||||
usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10 },
|
||||
total_cost_usd: 0.001,
|
||||
}),
|
||||
].join("\n") + "\n";
|
||||
|
||||
// ─── execute: concurrency guard paths ────────────────────────────────────────
|
||||
|
||||
describe("execute: concurrency guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReadSkillEntries.mockResolvedValue([]);
|
||||
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
|
||||
});
|
||||
|
||||
it("returns k8s_concurrency_guard_unreachable when listNamespacedJob throws", async () => {
|
||||
mockBatchListJobs.mockRejectedValue(new Error("K8s API unavailable"));
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBe("k8s_concurrency_guard_unreachable");
|
||||
expect(result.errorMessage).toContain("K8s API unavailable");
|
||||
});
|
||||
|
||||
it("returns k8s_concurrent_run_blocked when reattach disabled and orphan is running", async () => {
|
||||
const orphan = makeJob({ runId: "prior-run", agentId: "agent-abc", terminal: false });
|
||||
mockBatchListJobs.mockResolvedValue({ items: [orphan] });
|
||||
const result = await execute(makeCtx({ config: { reattachOrphanedJobs: false } } as Partial<AdapterExecutionContext>));
|
||||
expect(result.errorCode).toBe("k8s_concurrent_run_blocked");
|
||||
expect(result.errorMessage).toContain("reattach disabled");
|
||||
});
|
||||
|
||||
it("returns k8s_orphan_task_unknown when orphan has no task label", async () => {
|
||||
// No taskId on the orphan job and no taskId in context → block_task_unknown
|
||||
const orphan = makeJob({ runId: "prior-run", agentId: "agent-abc" }); // no taskId label
|
||||
mockBatchListJobs.mockResolvedValue({ items: [orphan] });
|
||||
// context.taskId absent → currentTaskLabel = null → block_task_unknown
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBe("k8s_orphan_task_unknown");
|
||||
});
|
||||
|
||||
it("returns k8s_concurrent_run_blocked when orphan task-id mismatches current task", async () => {
|
||||
const orphan = makeJob({ runId: "prior-run", agentId: "agent-abc", taskId: "task-other" });
|
||||
mockBatchListJobs.mockResolvedValue({ items: [orphan] });
|
||||
const result = await execute(
|
||||
makeCtx({ context: { taskId: "task-current" } } as Partial<AdapterExecutionContext>),
|
||||
);
|
||||
expect(result.errorCode).toBe("k8s_concurrent_run_blocked");
|
||||
expect(result.errorMessage).toContain("different task");
|
||||
});
|
||||
|
||||
it("returns k8s_orphan_session_mismatch when task matches but session differs", async () => {
|
||||
const orphan = makeJob({
|
||||
runId: "prior-run",
|
||||
agentId: "agent-abc",
|
||||
taskId: "task-match",
|
||||
sessionId: "sess-other",
|
||||
});
|
||||
mockBatchListJobs.mockResolvedValue({ items: [orphan] });
|
||||
const result = await execute(
|
||||
makeCtx({
|
||||
context: { taskId: "task-match" },
|
||||
runtime: { sessionId: "sess-current", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
} as Partial<AdapterExecutionContext>),
|
||||
);
|
||||
expect(result.errorCode).toBe("k8s_orphan_session_mismatch");
|
||||
expect(result.errorMessage).toContain("mismatched session");
|
||||
});
|
||||
|
||||
it("returns k8s_concurrent_run_blocked when same-run job is still running", async () => {
|
||||
// runId matches → samRun.length > 0 → blocked
|
||||
const sameRunJob = makeJob({ runId: "run-test-001", agentId: "agent-abc", terminal: false });
|
||||
mockBatchListJobs.mockResolvedValue({ items: [sameRunJob] });
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBe("k8s_concurrent_run_blocked");
|
||||
expect(result.errorMessage).toContain("still running for this agent");
|
||||
});
|
||||
|
||||
it("ignores terminating jobs (deletionTimestamp set) and proceeds past the concurrency guard", async () => {
|
||||
// A job being force-deleted has deletionTimestamp set but no Complete/Failed condition.
|
||||
// The guard must treat it as terminal so subsequent runs are not blocked.
|
||||
const terminating: k8s.V1Job = {
|
||||
metadata: {
|
||||
name: "terminating-job",
|
||||
namespace: "paperclip",
|
||||
labels: { "paperclip.io/agent-id": "agent-abc", "paperclip.io/adapter-type": "claude_k8s" },
|
||||
deletionTimestamp: new Date(),
|
||||
},
|
||||
status: { conditions: [] },
|
||||
};
|
||||
mockBatchListJobs.mockResolvedValue({ items: [terminating] });
|
||||
// Guard passes → next failure is job creation (no further mocks set up)
|
||||
mockBatchCreateJob.mockRejectedValue(new Error("quota exceeded"));
|
||||
mockPrepareBundle.mockResolvedValue(makeBundle());
|
||||
const result = await execute(makeCtx());
|
||||
// Must NOT be a concurrency error — the guard let us through
|
||||
expect(result.errorCode).not.toBe("k8s_concurrent_run_blocked");
|
||||
expect(result.errorCode).toBe("k8s_job_create_failed");
|
||||
});
|
||||
|
||||
it("reattaches to a matching orphan and returns k8s_pod_reattach_failed when pod is missing", async () => {
|
||||
// Orphan with matching taskId and sessionId → reattach classification → reattachTarget is set
|
||||
const orphan = makeJob({
|
||||
runId: "prior-run",
|
||||
agentId: "agent-abc",
|
||||
taskId: "task-match",
|
||||
sessionId: "sess-match",
|
||||
});
|
||||
mockBatchListJobs.mockResolvedValue({ items: [orphan] });
|
||||
mockBatchPatchJob.mockResolvedValue({});
|
||||
mockPrepareBundle.mockResolvedValue(makeBundle());
|
||||
// Pod lookup finds nothing → reattach pod-not-found error
|
||||
mockCoreListPods.mockResolvedValue({ items: [] });
|
||||
|
||||
const result = await execute(
|
||||
makeCtx({
|
||||
context: { taskId: "task-match" },
|
||||
runtime: { sessionId: "sess-match", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
} as Partial<AdapterExecutionContext>),
|
||||
);
|
||||
|
||||
expect(result.errorCode).toBe("k8s_pod_reattach_failed");
|
||||
expect(result.errorMessage).toContain("no pod");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── execute: job creation paths ─────────────────────────────────────────────
|
||||
|
||||
describe("execute: job creation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockReadSkillEntries.mockResolvedValue([]);
|
||||
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
|
||||
mockBatchListJobs.mockResolvedValue({ items: [] }); // no concurrent jobs
|
||||
mockPrepareBundle.mockResolvedValue(makeBundle());
|
||||
mockBatchCreateJob.mockResolvedValue({ metadata: { uid: "job-uid-1" } });
|
||||
mockBatchDeleteJob.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("returns k8s_job_create_failed when createNamespacedJob throws", async () => {
|
||||
mockBatchCreateJob.mockRejectedValue(new Error("quota exceeded"));
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBe("k8s_job_create_failed");
|
||||
expect(result.errorMessage).toContain("quota exceeded");
|
||||
});
|
||||
|
||||
it("returns k8s_pod_schedule_failed when pod scheduling times out", async () => {
|
||||
mockBatchCreateJob.mockResolvedValue({ metadata: { uid: "uid-1" } });
|
||||
mockBatchDeleteJob.mockResolvedValue({});
|
||||
// Pod never appears → waitForPod eventually times out.
|
||||
// Provide a config with very short timeout to avoid a real 2-minute wait.
|
||||
// Instead, make listNamespacedPod return an unschedulable condition immediately.
|
||||
mockCoreListPods.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "pod-xyz" },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
conditions: [
|
||||
{ type: "PodScheduled", status: "False", reason: "Unschedulable", message: "no nodes available" },
|
||||
],
|
||||
containerStatuses: [],
|
||||
initContainerStatuses: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await execute(makeCtx());
|
||||
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toContain("unschedulable");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── execute: waitForPod edge cases ──────────────────────────────────────────
|
||||
|
||||
describe("execute: waitForPod edge cases", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockReadSkillEntries.mockResolvedValue([]);
|
||||
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
|
||||
mockBatchListJobs.mockResolvedValue({ items: [] });
|
||||
mockPrepareBundle.mockResolvedValue(makeBundle());
|
||||
mockBatchCreateJob.mockResolvedValue({ metadata: { uid: "uid-1" } });
|
||||
mockBatchDeleteJob.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("throws k8s_pod_schedule_failed when pod reaches phase=Failed immediately", async () => {
|
||||
mockCoreListPods.mockResolvedValue({
|
||||
items: [{
|
||||
metadata: { name: "pod-fail" },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
containerStatuses: [{ name: "claude", state: { terminated: { exitCode: 137, reason: "OOMKilled" } } }],
|
||||
initContainerStatuses: [],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
const result = await execute(makeCtx());
|
||||
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toContain("OOMKilled");
|
||||
});
|
||||
|
||||
it("throws k8s_pod_schedule_failed when init container exits non-zero", async () => {
|
||||
mockCoreListPods.mockResolvedValue({
|
||||
items: [{
|
||||
metadata: { name: "pod-x" },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
initContainerStatuses: [{
|
||||
name: "write-prompt",
|
||||
state: { terminated: { exitCode: 1, reason: "Error" } },
|
||||
}],
|
||||
containerStatuses: [],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
const result = await execute(makeCtx());
|
||||
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toContain("write-prompt");
|
||||
});
|
||||
|
||||
it("throws k8s_pod_schedule_failed when init container has ImagePullBackOff", async () => {
|
||||
mockCoreListPods.mockResolvedValue({
|
||||
items: [{
|
||||
metadata: { name: "pod-x" },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
initContainerStatuses: [{
|
||||
name: "write-prompt",
|
||||
state: { waiting: { reason: "ImagePullBackOff", message: "pull failed" } },
|
||||
}],
|
||||
containerStatuses: [],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
const result = await execute(makeCtx());
|
||||
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toContain("image pull");
|
||||
});
|
||||
|
||||
it("throws k8s_pod_schedule_failed when main container has CrashLoopBackOff", async () => {
|
||||
mockCoreListPods.mockResolvedValue({
|
||||
items: [{
|
||||
metadata: { name: "pod-x" },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
initContainerStatuses: [],
|
||||
containerStatuses: [{
|
||||
name: "claude",
|
||||
state: { waiting: { reason: "CrashLoopBackOff" } },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
const result = await execute(makeCtx());
|
||||
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toContain("crash loop");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── execute: grace-period fallback (FAR-23) ─────────────────────────────────
|
||||
|
||||
|
||||
// ─── execute: concurrency guard — multiple orphan sorting ────────────────────
|
||||
|
||||
describe("execute: concurrency guard — multiple orphans", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
|
||||
});
|
||||
|
||||
it("sorts multiple orphans newest-first and processes them in that order", async () => {
|
||||
// orphanNew has a newer timestamp and a mismatching task → block_task_mismatch
|
||||
// orphanOld has an older timestamp and a matching task → would reattach
|
||||
// The sort (lines 603-605) must put orphanNew first so it is the one classified.
|
||||
const orphanOld = makeJob({ runId: "prior-1", agentId: "agent-abc", taskId: "task-match" });
|
||||
orphanOld.metadata!.creationTimestamp = new Date("2024-01-01T00:00:00Z") as unknown as Date;
|
||||
const orphanNew = makeJob({ runId: "prior-2", agentId: "agent-abc", taskId: "task-other" });
|
||||
orphanNew.metadata!.creationTimestamp = new Date("2024-01-02T00:00:00Z") as unknown as Date;
|
||||
|
||||
mockBatchListJobs.mockResolvedValue({ items: [orphanOld, orphanNew] });
|
||||
const result = await execute(
|
||||
makeCtx({ context: { taskId: "task-match" } } as Partial<AdapterExecutionContext>),
|
||||
);
|
||||
|
||||
// Newest orphan (task-other) is classified first → block_task_mismatch
|
||||
expect(result.errorCode).toBe("k8s_concurrent_run_blocked");
|
||||
expect(result.errorMessage).toContain("different task");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shouldAbortForCancellation ──────────────────────────────────────────────
|
||||
|
||||
describe("shouldAbortForCancellation", () => {
|
||||
it("returns false for undefined", () => {
|
||||
expect(shouldAbortForCancellation(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(shouldAbortForCancellation("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when status is 'running'", () => {
|
||||
expect(shouldAbortForCancellation("running")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when status is 'cancelled'", () => {
|
||||
expect(shouldAbortForCancellation("cancelled")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when status is 'cancelling'", () => {
|
||||
expect(shouldAbortForCancellation("cancelling")).toBe(true);
|
||||
});
|
||||
|
||||
// FAR-107: terminal-but-not-cancelled statuses MUST NOT trigger Job deletion.
|
||||
// The previous "anything but running" guard caused k8s_job_deleted_externally
|
||||
// false positives for in-flight runs whenever the API briefly reported a
|
||||
// transient/stale status.
|
||||
it("returns false for non-cancellation terminal statuses (FAR-107)", () => {
|
||||
expect(shouldAbortForCancellation("succeeded")).toBe(false);
|
||||
expect(shouldAbortForCancellation("failed")).toBe(false);
|
||||
expect(shouldAbortForCancellation("completed")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown statuses (FAR-107)", () => {
|
||||
expect(shouldAbortForCancellation("unknown")).toBe(false);
|
||||
expect(shouldAbortForCancellation("queued")).toBe(false);
|
||||
expect(shouldAbortForCancellation("pending")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ─── execute: per-agent creation mutex (FAR-29 TOCTOU fix) ───────────────────
|
||||
//
|
||||
// Verifies that two concurrent execute() calls for the same agent cannot both
|
||||
// enter the listNamespacedJob → createNamespacedJob sequence simultaneously.
|
||||
// Without the per-agent mutex, both would pass the concurrency guard before
|
||||
// either job appears in the other's list query.
|
||||
|
||||
describe("execute: per-agent creation mutex prevents TOCTOU race", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockReadSkillEntries.mockResolvedValue([]);
|
||||
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
|
||||
mockPrepareBundle.mockResolvedValue(makeBundle());
|
||||
// Make job creation fail so the guard+create phase exits quickly and
|
||||
// releases the mutex without needing to mock the full streaming path.
|
||||
mockBatchCreateJob.mockRejectedValue(new Error("mock: create not configured"));
|
||||
mockBatchDeleteJob.mockResolvedValue({});
|
||||
mockCoreDeleteSecret.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("serializes guard phases for the same agent: call-2 waits until call-1 exits guard+create", async () => {
|
||||
const listCalls: string[] = [];
|
||||
let resolveFirstList!: (v: { items: [] }) => void;
|
||||
|
||||
mockBatchListJobs
|
||||
.mockImplementationOnce(() => {
|
||||
listCalls.push("call-1");
|
||||
return new Promise<{ items: [] }>((resolve) => { resolveFirstList = resolve; });
|
||||
})
|
||||
.mockImplementation(() => {
|
||||
listCalls.push("call-2");
|
||||
return Promise.resolve({ items: [] });
|
||||
});
|
||||
|
||||
const p1 = execute(makeCtx({ runId: "run-1" }));
|
||||
const p2 = execute(makeCtx({ runId: "run-2" }));
|
||||
|
||||
// Drain microtasks: call-1 should be suspended in listNamespacedJob while
|
||||
// call-2 waits behind the per-agent mutex, not yet calling list.
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
expect(listCalls).toEqual(["call-1"]);
|
||||
|
||||
// Let call-1's guard resolve (no running jobs). It will proceed to job
|
||||
// creation, fail (mock rejects), and release the mutex in finally.
|
||||
resolveFirstList({ items: [] });
|
||||
await Promise.allSettled([p1, p2]);
|
||||
|
||||
// call-2 must have listed, and only AFTER call-1's guard resolved.
|
||||
// The exact order: call-1 listed → call-1 list resolved → call-2 listed.
|
||||
expect(listCalls).toEqual(["call-1", "call-2"]);
|
||||
});
|
||||
|
||||
it("does not serialize guard phases for different agents", async () => {
|
||||
const listCalls: string[] = [];
|
||||
let resolveAgentAList!: (v: { items: [] }) => void;
|
||||
|
||||
// Agent A's list is artificially slow. Agent B (different id) should
|
||||
// proceed immediately without waiting — the mutex is keyed by agent id.
|
||||
mockBatchListJobs
|
||||
.mockImplementationOnce(() => {
|
||||
listCalls.push("A");
|
||||
return new Promise<{ items: [] }>((resolve) => { resolveAgentAList = resolve; });
|
||||
})
|
||||
.mockImplementation(() => {
|
||||
listCalls.push("B");
|
||||
return Promise.resolve({ items: [] });
|
||||
});
|
||||
|
||||
const ctxA = makeCtx({ runId: "run-A" });
|
||||
const ctxB = makeCtx({
|
||||
runId: "run-B",
|
||||
agent: { id: "agent-other", companyId: "co1", name: "Other Agent", adapterType: "claude_k8s", adapterConfig: {} },
|
||||
} as Partial<AdapterExecutionContext>);
|
||||
|
||||
const pA = execute(ctxA);
|
||||
const pB = execute(ctxB);
|
||||
|
||||
// Drain microtasks — B should have called list even though A is still
|
||||
// suspended, because they use separate mutex slots.
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
expect(listCalls).toContain("B");
|
||||
|
||||
// Let A complete so the promises settle cleanly.
|
||||
resolveAgentAList({ items: [] });
|
||||
await Promise.allSettled([pA, pB]);
|
||||
});
|
||||
});
|
||||
|
||||
+938
-338
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -33,9 +33,13 @@ export function createServerAdapter(): ServerAdapterModule {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
// Tells the reaper to skip local PID checks and use the staleness-based
|
||||
// liveness window instead (adapter spawns K8s Jobs in separate pods).
|
||||
// Cast required: adapter-utils ServerAdapterModule type predates this field.
|
||||
hasOutOfProcessLiveness: true,
|
||||
agentConfigurationDoc,
|
||||
getConfigSchema,
|
||||
};
|
||||
} as ServerAdapterModule;
|
||||
}
|
||||
|
||||
export { execute, testEnvironment, sessionCodec };
|
||||
|
||||
+234
-74
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { buildJobManifest, buildRtkSetupCommands } from "./job-manifest.js";
|
||||
import { buildJobManifest, buildPodLogPath, sanitizeLabelValue } from "./job-manifest.js";
|
||||
import type { SelfPodInfo } from "./k8s-client.js";
|
||||
|
||||
function makeCtx(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
|
||||
@@ -136,6 +136,103 @@ describe("buildJobManifest", () => {
|
||||
expect(job.metadata?.labels?.env).toBe("prod");
|
||||
expect(job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("claude_k8s");
|
||||
});
|
||||
|
||||
it("adds task-id label when context provides taskId", () => {
|
||||
ctx.context = { taskId: "task-xyz-789" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("task-xyz-789");
|
||||
});
|
||||
|
||||
it("falls back to issueId when taskId absent", () => {
|
||||
ctx.context = { issueId: "issue-42" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("issue-42");
|
||||
});
|
||||
|
||||
it("adds session-id label when runtime provides sessionId", () => {
|
||||
ctx.runtime = { ...ctx.runtime, sessionId: "sess-abc-1234" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-abc-1234");
|
||||
});
|
||||
|
||||
it("reads sessionId from runtime.sessionParams when sessionId prop missing", () => {
|
||||
ctx.runtime = { ...ctx.runtime, sessionParams: { sessionId: "sess-from-params" } };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-from-params");
|
||||
});
|
||||
|
||||
it("omits task-id and session-id labels when neither is provided", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined();
|
||||
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops user label with paperclip.io/ prefix", () => {
|
||||
ctx.config = { labels: { "paperclip.io/run-id": "hijacked" } };
|
||||
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/run-id"]).not.toBe("hijacked");
|
||||
expect(skippedLabels).toContain("paperclip.io/run-id");
|
||||
});
|
||||
|
||||
it("drops user label with app.kubernetes.io/ prefix", () => {
|
||||
ctx.config = { labels: { "app.kubernetes.io/managed-by": "attacker" } };
|
||||
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["app.kubernetes.io/managed-by"]).toBe("paperclip");
|
||||
expect(skippedLabels).toContain("app.kubernetes.io/managed-by");
|
||||
});
|
||||
|
||||
it("passes through user label without reserved prefix", () => {
|
||||
ctx.config = { labels: { "custom.io/team": "platform" } };
|
||||
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["custom.io/team"]).toBe("platform");
|
||||
expect(skippedLabels).not.toContain("custom.io/team");
|
||||
});
|
||||
|
||||
it("populates skippedLabels with all dropped keys", () => {
|
||||
ctx.config = {
|
||||
labels: {
|
||||
"paperclip.io/agent-id": "x",
|
||||
"app.kubernetes.io/component": "y",
|
||||
"safe": "z",
|
||||
},
|
||||
};
|
||||
const { skippedLabels } = buildJobManifest({ ctx, selfPod });
|
||||
expect(skippedLabels).toHaveLength(2);
|
||||
expect(skippedLabels).toContain("paperclip.io/agent-id");
|
||||
expect(skippedLabels).toContain("app.kubernetes.io/component");
|
||||
});
|
||||
});
|
||||
|
||||
describe("system label sanitization (N4)", () => {
|
||||
it("sanitizes agent.id with @ to a valid RFC 1123 label", () => {
|
||||
ctx.agent.id = "user@example.com";
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const label = job.metadata?.labels?.["paperclip.io/agent-id"];
|
||||
expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/);
|
||||
expect(label).not.toContain("@");
|
||||
});
|
||||
|
||||
it("sanitizes agent.id with spaces to a valid RFC 1123 label", () => {
|
||||
ctx.agent.id = "my agent id";
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const label = job.metadata?.labels?.["paperclip.io/agent-id"];
|
||||
expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/);
|
||||
});
|
||||
|
||||
it("omits paperclip.io/run-id when sanitized value is null (all-invalid runId)", () => {
|
||||
// inject an all-special-chars runId via context override — buildJobManifest
|
||||
// uses ctx.runId directly. Use characters that are path-valid but label-invalid.
|
||||
const badCtx = makeCtx({ runId: "@@@" });
|
||||
expect(() => buildJobManifest({ ctx: badCtx, selfPod })).toThrow("Invalid runId");
|
||||
});
|
||||
|
||||
it("selector matches sanitized agent-id label", () => {
|
||||
ctx.agent.id = "Agent@Test";
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const agentLabel = job.metadata?.labels?.["paperclip.io/agent-id"];
|
||||
// the label should equal what sanitizeLabelValue produces
|
||||
expect(agentLabel).toBe("AgentTest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("annotations", () => {
|
||||
@@ -202,7 +299,9 @@ describe("buildJobManifest", () => {
|
||||
it("write-prompt writes PROMPT_CONTENT to /tmp/prompt/prompt.txt", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const init = job.spec?.template?.spec?.initContainers?.[0];
|
||||
expect(init?.command).toEqual(["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"]);
|
||||
expect(init?.command?.[0]).toBe("sh");
|
||||
expect(init?.command?.[1]).toBe("-c");
|
||||
expect(init?.command?.[2]).toBe("printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt");
|
||||
});
|
||||
|
||||
it("write-prompt mounts prompt volume", () => {
|
||||
@@ -408,10 +507,10 @@ describe("buildJobManifest", () => {
|
||||
|
||||
it("uses configured resource overrides", () => {
|
||||
ctx.config = {
|
||||
resources: {
|
||||
requests: { cpu: "500m", memory: "1Gi" },
|
||||
limits: { cpu: "2000m", memory: "4Gi" },
|
||||
},
|
||||
"resources.requests.cpu": "500m",
|
||||
"resources.requests.memory": "1Gi",
|
||||
"resources.limits.cpu": "2000m",
|
||||
"resources.limits.memory": "4Gi",
|
||||
};
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const resources = job.spec?.template?.spec?.containers[0]?.resources;
|
||||
@@ -487,13 +586,66 @@ describe("buildJobManifest", () => {
|
||||
expect(claudeArgs).toContain("--dangerously-skip-permissions");
|
||||
});
|
||||
|
||||
it("adds --append-system-prompt-file when instructionsFilePath set", () => {
|
||||
it("adds --append-system-prompt-file (config fallback) when instructionsFilePath set and no session", () => {
|
||||
ctx.config = { instructionsFilePath: "/paperclip/instructions.md" };
|
||||
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
||||
expect(claudeArgs).toContain("--append-system-prompt-file");
|
||||
expect(claudeArgs).toContain("/paperclip/instructions.md");
|
||||
});
|
||||
|
||||
it("omits --append-system-prompt-file on session resume (avoids token waste)", () => {
|
||||
ctx.config = { instructionsFilePath: "/paperclip/instructions.md" };
|
||||
ctx.runtime.sessionId = "sess_existing";
|
||||
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
||||
expect(claudeArgs).not.toContain("--append-system-prompt-file");
|
||||
});
|
||||
|
||||
it("adds --add-dir when promptBundle is provided", () => {
|
||||
const promptBundle = {
|
||||
bundleKey: "abc123",
|
||||
rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
||||
addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
||||
instructionsFilePath: null,
|
||||
};
|
||||
const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle });
|
||||
expect(claudeArgs).toContain("--add-dir");
|
||||
expect(claudeArgs).toContain(promptBundle.addDir);
|
||||
});
|
||||
|
||||
it("uses bundle instructionsFilePath for --append-system-prompt-file when promptBundle provided", () => {
|
||||
const promptBundle = {
|
||||
bundleKey: "abc123",
|
||||
rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
||||
addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
||||
instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md",
|
||||
};
|
||||
ctx.config = { instructionsFilePath: "/raw/path/AGENTS.md" };
|
||||
const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle });
|
||||
expect(claudeArgs).toContain("--append-system-prompt-file");
|
||||
const idx = claudeArgs.indexOf("--append-system-prompt-file");
|
||||
expect(claudeArgs[idx + 1]).toBe(promptBundle.instructionsFilePath);
|
||||
expect(claudeArgs).not.toContain("/raw/path/AGENTS.md");
|
||||
});
|
||||
|
||||
it("omits --append-system-prompt-file from bundle on session resume", () => {
|
||||
const promptBundle = {
|
||||
bundleKey: "abc123",
|
||||
rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
||||
addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
||||
instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md",
|
||||
};
|
||||
ctx.runtime.sessionId = "sess_existing";
|
||||
const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle });
|
||||
expect(claudeArgs).not.toContain("--append-system-prompt-file");
|
||||
// --add-dir must still be present even on resume
|
||||
expect(claudeArgs).toContain("--add-dir");
|
||||
});
|
||||
|
||||
it("omits --add-dir when no promptBundle", () => {
|
||||
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
||||
expect(claudeArgs).not.toContain("--add-dir");
|
||||
});
|
||||
|
||||
it("appends extraArgs when configured", () => {
|
||||
ctx.config = { extraArgs: ["--no-input", "--verbose"] };
|
||||
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
||||
@@ -642,90 +794,98 @@ describe("buildJobManifest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("rtk output filtering", () => {
|
||||
describe("pod log file tailing", () => {
|
||||
it("does not modify main command when enableRtk is false (default)", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command;
|
||||
// Command should be the plain `cat ... | claude ...` form with no rtk setup
|
||||
expect(cmd?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt \| claude /);
|
||||
// Command should be the plain `cat ... | claude ... | tee ...` form with no rtk setup
|
||||
expect(cmd?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt \| claude .* \| tee /);
|
||||
expect(cmd?.[2]).not.toContain("rtk-filter");
|
||||
});
|
||||
|
||||
it("prepends RTK setup commands when enableRtk is true", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command;
|
||||
expect(cmd?.[2]).toContain(".rtk-filter.js");
|
||||
expect(cmd?.[2]).toContain("cat /tmp/prompt/prompt.txt | claude");
|
||||
});
|
||||
|
||||
it("RTK setup runs before claude invocation", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
it("command includes tee to pod log path", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
||||
const rtkIdx = cmd.indexOf(".rtk-filter.js");
|
||||
const claudeIdx = cmd.indexOf("cat /tmp/prompt/prompt.txt | claude");
|
||||
expect(rtkIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(claudeIdx).toBeGreaterThan(rtkIdx);
|
||||
expect(cmd).toContain("| tee");
|
||||
expect(cmd).toContain("/paperclip/instances/default/data/run-logs/");
|
||||
});
|
||||
|
||||
it("RTK setup uses node (no external binaries)", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
it("podLogPath is returned from buildJobManifest", () => {
|
||||
const result = buildJobManifest({ ctx, selfPod });
|
||||
expect(result.podLogPath).toBe(
|
||||
"/paperclip/instances/default/data/run-logs/co1/agent-abc/run-abc12345.pod.ndjson",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildPodLogPath returns correctly formatted path", () => {
|
||||
expect(buildPodLogPath("co1", "agent-abc", "run-abc12345")).toBe(
|
||||
"/paperclip/instances/default/data/run-logs/co1/agent-abc/run-abc12345.pod.ndjson",
|
||||
);
|
||||
});
|
||||
|
||||
it("init container does not create log directory (server pre-creates it on shared PVC)", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
||||
// Should only use `node` — no curl, wget, apt, pip, etc.
|
||||
expect(cmd).not.toMatch(/\b(curl|wget|apt|yum|pip|gem|cargo|go\s+get)\b/);
|
||||
expect(cmd).toContain("node ");
|
||||
const initCmd = job.spec?.template?.spec?.initContainers?.[0]?.command;
|
||||
expect(initCmd?.[2]).not.toContain("mkdir -p /paperclip");
|
||||
});
|
||||
|
||||
it("uses default 50000 byte threshold when rtkMaxOutputBytes not set", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
const setup = buildRtkSetupCommands(50000);
|
||||
// The filter script base64 should decode to contain the MAX constant
|
||||
const b64Match = setup.match(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/);
|
||||
expect(b64Match).not.toBeNull();
|
||||
const decoded = Buffer.from(b64Match![1], "base64").toString("utf-8");
|
||||
expect(decoded).toContain("50000");
|
||||
it("sanitizes companyId with / to valid path component for log path", () => {
|
||||
const badCtx = {
|
||||
...ctx,
|
||||
agent: { ...ctx.agent, companyId: "co/1" },
|
||||
};
|
||||
const { podLogPath } = buildJobManifest({ ctx: badCtx as typeof ctx, selfPod });
|
||||
// / is stripped by sanitizeForK8sPath
|
||||
expect(podLogPath).toContain("co1/");
|
||||
});
|
||||
|
||||
it("respects custom rtkMaxOutputBytes", () => {
|
||||
ctx.config = { enableRtk: true, rtkMaxOutputBytes: 100000 };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
||||
// The custom threshold should appear in the base64-encoded filter script
|
||||
const b64Matches = [...cmd.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
const decoded = b64Matches.map((m) => Buffer.from(m[1], "base64").toString("utf-8")).join("\n");
|
||||
expect(decoded).toContain("100000");
|
||||
it("sanitizes agentId with @ to valid path component for log path", () => {
|
||||
const badCtx = {
|
||||
...ctx,
|
||||
agent: { ...ctx.agent, id: "agent@123" },
|
||||
};
|
||||
const { podLogPath } = buildJobManifest({ ctx: badCtx as typeof ctx, selfPod });
|
||||
// @ is stripped by sanitizeForK8sPath
|
||||
expect(podLogPath).toContain("/agent123/");
|
||||
});
|
||||
|
||||
it("RTK setup installs a PostToolUse hook in claude settings", () => {
|
||||
const setup = buildRtkSetupCommands(50000);
|
||||
// The settings script (second base64 block) should reference PostToolUse
|
||||
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
expect(b64Matches.length).toBeGreaterThanOrEqual(2);
|
||||
const settingsScript = Buffer.from(b64Matches[1]![1], "base64").toString("utf-8");
|
||||
expect(settingsScript).toContain("PostToolUse");
|
||||
expect(settingsScript).toContain("settings.json");
|
||||
});
|
||||
|
||||
it("filter script handles string content truncation", () => {
|
||||
// Decode the filter script and verify it truncates string content
|
||||
const setup = buildRtkSetupCommands(1000);
|
||||
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8");
|
||||
expect(filterScript).toContain("MAX=1000");
|
||||
expect(filterScript).toContain("truncated by paperclip-rtk");
|
||||
expect(filterScript).toContain("tool_response");
|
||||
expect(filterScript).toContain("tool_result");
|
||||
});
|
||||
|
||||
it("filter script handles array content (block format)", () => {
|
||||
const setup = buildRtkSetupCommands(50000);
|
||||
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8");
|
||||
// Should handle array content blocks (text field on each block)
|
||||
expect(filterScript).toContain("Array.isArray");
|
||||
expect(filterScript).toContain("b.text");
|
||||
it("sanitizes runId with underscore to valid path component for log path", () => {
|
||||
const badCtx = {
|
||||
...ctx,
|
||||
runId: "run_123",
|
||||
};
|
||||
const { podLogPath } = buildJobManifest({ ctx: badCtx as typeof ctx, selfPod });
|
||||
// _ is stripped by sanitizeForK8sPath
|
||||
expect(podLogPath).toContain("/run123.pod.ndjson");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeLabelValue", () => {
|
||||
it("passes through already-valid UUIDs and slugs", () => {
|
||||
expect(sanitizeLabelValue("abc-123-def")).toBe("abc-123-def");
|
||||
expect(sanitizeLabelValue("0d8b4472-c42c-4052-aab1-e32897909afa")).toBe("0d8b4472-c42c-4052-aab1-e32897909afa");
|
||||
});
|
||||
|
||||
it("strips characters outside [a-zA-Z0-9._-]", () => {
|
||||
expect(sanitizeLabelValue("task:xyz/123")).toBe("taskxyz123");
|
||||
expect(sanitizeLabelValue("abc 123")).toBe("abc123");
|
||||
});
|
||||
|
||||
it("trims leading/trailing non-alphanumeric characters", () => {
|
||||
expect(sanitizeLabelValue("--abc--")).toBe("abc");
|
||||
expect(sanitizeLabelValue("...123...")).toBe("123");
|
||||
});
|
||||
|
||||
it("truncates to the configured maxLen", () => {
|
||||
const long = "a".repeat(200);
|
||||
const out = sanitizeLabelValue(long, 63);
|
||||
expect(out?.length).toBe(63);
|
||||
});
|
||||
|
||||
it("returns null when no alphanumeric characters remain", () => {
|
||||
expect(sanitizeLabelValue("---")).toBeNull();
|
||||
expect(sanitizeLabelValue("")).toBeNull();
|
||||
expect(sanitizeLabelValue(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+84
-104
@@ -10,85 +10,20 @@ import {
|
||||
renderTemplate,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ClaudePromptBundle } from "./prompt-cache.js";
|
||||
|
||||
/**
|
||||
* Build the shell command prefix that installs a native Node.js PostToolUse
|
||||
* hook into Claude Code's settings. The hook truncates oversized tool outputs
|
||||
* before they reach the model — replacing the RTK binary init-container
|
||||
* approach with a self-contained Node.js implementation.
|
||||
*
|
||||
* Both scripts are base64-encoded so they can be embedded in a sh -c command
|
||||
* string without any quoting or escaping issues.
|
||||
*
|
||||
* @param maxOutputBytes Byte threshold above which tool output is truncated.
|
||||
* @returns A shell command string (suitable for "&&"-chaining
|
||||
* before the claude invocation).
|
||||
*/
|
||||
export function buildRtkSetupCommands(maxOutputBytes: number): string {
|
||||
// --- Filter script ----------------------------------------------------------
|
||||
// This script runs as the PostToolUse hook inside every K8s Job pod.
|
||||
// Claude Code writes the hook event as JSON to the script's stdin; the script
|
||||
// truncates the tool_response/tool_result content when it exceeds the
|
||||
// threshold and writes the (possibly modified) JSON to stdout.
|
||||
//
|
||||
// Field-name coverage:
|
||||
// • tool_response — documented hook event format for PostToolUse
|
||||
// • tool_result — alternative name seen in some Claude Code versions
|
||||
// Content may be a plain string or an array of typed blocks (text/image/…).
|
||||
const filterScript = [
|
||||
`const c=[];`,
|
||||
`process.stdin.on('data',d=>c.push(d));`,
|
||||
`process.stdin.on('end',()=>{`,
|
||||
`const raw=Buffer.concat(c).toString('utf-8');`,
|
||||
`let o;try{o=JSON.parse(raw);}catch{process.stdout.write(raw);return;}`,
|
||||
`const MAX=${maxOutputBytes};`,
|
||||
`function trunc(s){`,
|
||||
`if(typeof s!=='string')return s;`,
|
||||
`const b=Buffer.from(s,'utf-8');`,
|
||||
`if(b.length<=MAX)return s;`,
|
||||
`return b.slice(0,MAX).toString('utf-8')+'\\n[...'+(b.length-MAX)+' bytes truncated by paperclip-rtk]';`,
|
||||
`}`,
|
||||
`const tr=o&&(o.tool_response||o.tool_result);`,
|
||||
`if(tr){`,
|
||||
`if(typeof tr.content==='string'){tr.content=trunc(tr.content);}`,
|
||||
`else if(Array.isArray(tr.content)){`,
|
||||
`tr.content=tr.content.map(function(b){`,
|
||||
`if(b&&typeof b==='object'&&typeof b.text==='string'){`,
|
||||
`return Object.assign({},b,{text:trunc(b.text)});`,
|
||||
`}return b;`,
|
||||
`});`,
|
||||
`}`,
|
||||
`}`,
|
||||
`process.stdout.write(JSON.stringify(o));`,
|
||||
`});`,
|
||||
].join("");
|
||||
function assertSafePathComponent(field: string, value: string): void {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(value)) {
|
||||
throw new Error(`Invalid ${field} for log path: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings script --------------------------------------------------------
|
||||
// Reads the existing ~/.claude/settings.json (if any), merges in the RTK
|
||||
// PostToolUse hook, and writes the file back. All other settings sections
|
||||
// are preserved; only PostToolUse is replaced so we own the full hook list
|
||||
// for this run.
|
||||
const settingsScript = [
|
||||
`const fs=require('fs'),pt=require('path');`,
|
||||
`const p=pt.join(process.env.HOME,'.claude','settings.json');`,
|
||||
`let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'));}catch(e){}`,
|
||||
`s.hooks=s.hooks||{};`,
|
||||
`s.hooks.PostToolUse=[{matcher:'.*',hooks:[{type:'command',command:'node /tmp/.rtk-filter.js'}]}];`,
|
||||
`fs.mkdirSync(pt.dirname(p),{recursive:true});`,
|
||||
`fs.writeFileSync(p,JSON.stringify(s));`,
|
||||
].join("");
|
||||
function sanitizeForK8sPath(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9-]/g, "");
|
||||
}
|
||||
|
||||
// Encode as base64 so the strings can be embedded directly in a shell command
|
||||
// without any quoting concerns (base64 alphabet: A-Za-z0-9+/=).
|
||||
const filterB64 = Buffer.from(filterScript, "utf-8").toString("base64");
|
||||
const settingsB64 = Buffer.from(settingsScript, "utf-8").toString("base64");
|
||||
|
||||
return [
|
||||
// Write the filter script
|
||||
`node -e "require('fs').writeFileSync('/tmp/.rtk-filter.js',Buffer.from('${filterB64}','base64').toString('utf-8'))"`,
|
||||
// Install the Claude Code PostToolUse hook (merge into existing settings)
|
||||
`node -e "eval(Buffer.from('${settingsB64}','base64').toString('utf-8'))"`,
|
||||
].join(" && ");
|
||||
export function buildPodLogPath(companyId: string, agentId: string, runId: string): string {
|
||||
return `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
|
||||
}
|
||||
|
||||
/** Prompts above this size (bytes) are staged via a Secret instead of an
|
||||
@@ -175,6 +110,8 @@ function parseKeyValueConfig(raw: unknown): Record<string, string> {
|
||||
export interface JobBuildInput {
|
||||
ctx: AdapterExecutionContext;
|
||||
selfPod: SelfPodInfo;
|
||||
/** Prepared prompt bundle (skills + instructions). When provided, --add-dir and --append-system-prompt-file use bundle paths. */
|
||||
promptBundle?: ClaudePromptBundle | null;
|
||||
}
|
||||
|
||||
/** When the prompt exceeds the env-var size limit, the manifest uses a
|
||||
@@ -196,10 +133,27 @@ export interface JobBuildResult {
|
||||
/** Non-null when the prompt is too large for an env var and must be
|
||||
* staged as a K8s Secret before creating the Job. */
|
||||
promptSecret: PromptSecret | null;
|
||||
/** User-supplied extra labels that were dropped because they used a reserved prefix. */
|
||||
skippedLabels: string[];
|
||||
/** Path to the pod log file on the shared PVC. */
|
||||
podLogPath: string;
|
||||
}
|
||||
|
||||
function sanitizeForK8sName(value: string, maxLen = 16): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, maxLen);
|
||||
// Trim trailing hyphens after slicing so names don't end with `-` when
|
||||
// truncation lands on a hyphen boundary (finding #16, FAR-15).
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, maxLen).replace(/-+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string for use as a Kubernetes label value (RFC 1123 subset:
|
||||
* `[a-zA-Z0-9]([-_.a-zA-Z0-9]*[a-zA-Z0-9])?`, max 63 chars). Returns `null`
|
||||
* when no usable characters remain — the caller should omit the label.
|
||||
*/
|
||||
export function sanitizeLabelValue(value: string, maxLen = 63): string | null {
|
||||
const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, maxLen);
|
||||
const trimmed = cleaned.replace(/^[^a-zA-Z0-9]+/, "").replace(/[^a-zA-Z0-9]+$/, "");
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,7 +270,7 @@ function buildEnvVars(
|
||||
}
|
||||
|
||||
export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
const { ctx, selfPod } = input;
|
||||
const { ctx, selfPod, promptBundle } = input;
|
||||
const { runId, agent, runtime, config: rawConfig, context } = ctx;
|
||||
const config = parseObject(rawConfig);
|
||||
|
||||
@@ -331,12 +285,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
const extraArgs = asStringArray(config.extraArgs);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
|
||||
const resources = parseObject(config.resources);
|
||||
const nodeSelector = parseKeyValueConfig(config.nodeSelector);
|
||||
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
|
||||
const extraLabels = parseKeyValueConfig(config.labels);
|
||||
const enableRtk = asBoolean(config.enableRtk, false);
|
||||
const rtkMaxOutputBytes = asNumber(config.rtkMaxOutputBytes, 50000);
|
||||
|
||||
// Resolve working directory — use workspace cwd, fall back to /paperclip
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
@@ -392,44 +343,71 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
};
|
||||
|
||||
// Build Claude CLI args
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
// Prefer the bundle's materialized instructions file over the raw config path.
|
||||
// Never inject --append-system-prompt-file on session resumes — the instructions
|
||||
// are already in the session cache and re-injecting wastes tokens.
|
||||
const rawInstructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const effectiveInstructionsFilePath =
|
||||
promptBundle?.instructionsFilePath ?? (rawInstructionsFilePath || null);
|
||||
const claudeArgs = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
if (runtimeSessionId) claudeArgs.push("--resume", runtimeSessionId);
|
||||
if (dangerouslySkipPermissions) claudeArgs.push("--dangerously-skip-permissions");
|
||||
if (model) claudeArgs.push("--model", model);
|
||||
if (effort) claudeArgs.push("--effort", effort);
|
||||
if (maxTurns > 0) claudeArgs.push("--max-turns", String(maxTurns));
|
||||
if (instructionsFilePath) claudeArgs.push("--append-system-prompt-file", instructionsFilePath);
|
||||
if (effectiveInstructionsFilePath && !runtimeSessionId) {
|
||||
claudeArgs.push("--append-system-prompt-file", effectiveInstructionsFilePath);
|
||||
}
|
||||
if (promptBundle) claudeArgs.push("--add-dir", promptBundle.addDir);
|
||||
if (extraArgs.length > 0) claudeArgs.push(...extraArgs);
|
||||
|
||||
// Build env vars
|
||||
const envVars = buildEnvVars(ctx, selfPod, config);
|
||||
|
||||
// Resource defaults
|
||||
const resourceRequests = parseObject(resources.requests);
|
||||
const resourceLimits = parseObject(resources.limits);
|
||||
// Resource defaults — UI stores dotted keys (e.g. "resources.requests.cpu")
|
||||
// as flat config entries, so read them directly from config with the dotted key.
|
||||
const containerResources: k8s.V1ResourceRequirements = {
|
||||
requests: {
|
||||
cpu: asString(resourceRequests.cpu, "1000m"),
|
||||
memory: asString(resourceRequests.memory, "2Gi"),
|
||||
cpu: asString(config["resources.requests.cpu"], "1000m"),
|
||||
memory: asString(config["resources.requests.memory"], "2Gi"),
|
||||
},
|
||||
limits: {
|
||||
cpu: asString(resourceLimits.cpu, "4000m"),
|
||||
memory: asString(resourceLimits.memory, "8Gi"),
|
||||
cpu: asString(config["resources.limits.cpu"], "4000m"),
|
||||
memory: asString(config["resources.limits.memory"], "8Gi"),
|
||||
},
|
||||
};
|
||||
|
||||
// Labels
|
||||
// Labels — system identifiers must pass RFC 1123 label value format.
|
||||
const sanitizedAgentId = sanitizeLabelValue(agent.id);
|
||||
const sanitizedRunId = sanitizeLabelValue(runId);
|
||||
const sanitizedCompanyId = sanitizeLabelValue(agent.companyId);
|
||||
const skippedLabels: string[] = [];
|
||||
if (!sanitizedRunId) skippedLabels.push("paperclip.io/run-id");
|
||||
if (!sanitizedCompanyId) skippedLabels.push("paperclip.io/company-id");
|
||||
const labels: Record<string, string> = {
|
||||
"app.kubernetes.io/managed-by": "paperclip",
|
||||
"app.kubernetes.io/component": "agent-job",
|
||||
"paperclip.io/agent-id": agent.id,
|
||||
"paperclip.io/run-id": runId,
|
||||
"paperclip.io/company-id": agent.companyId,
|
||||
// sanitizedAgentId null-check is enforced in execute.ts before Job creation
|
||||
"paperclip.io/agent-id": sanitizedAgentId ?? agent.id,
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
};
|
||||
if (sanitizedRunId) labels["paperclip.io/run-id"] = sanitizedRunId;
|
||||
if (sanitizedCompanyId) labels["paperclip.io/company-id"] = sanitizedCompanyId;
|
||||
// Reattach-target labels: let a future execute() identify this Job as the
|
||||
// continuation of the same logical unit of work (same task + same resume
|
||||
// session) so it can attach to the running pod across a Paperclip restart
|
||||
// instead of deleting it and starting over (FAR-124).
|
||||
const taskIdRaw = asString(context.taskId, "") || asString(context.issueId, "");
|
||||
const taskLabel = taskIdRaw ? sanitizeLabelValue(taskIdRaw) : null;
|
||||
if (taskLabel) labels["paperclip.io/task-id"] = taskLabel;
|
||||
const sessionLabel = runtimeSessionId ? sanitizeLabelValue(runtimeSessionId) : null;
|
||||
if (sessionLabel) labels["paperclip.io/session-id"] = sessionLabel;
|
||||
for (const [key, value] of Object.entries(extraLabels)) {
|
||||
labels[key] = value;
|
||||
if (key.startsWith("paperclip.io/") || key.startsWith("app.kubernetes.io/")) {
|
||||
skippedLabels.push(key);
|
||||
} else {
|
||||
labels[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Volumes
|
||||
@@ -490,13 +468,15 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
|
||||
// Build the claude command string for the main container
|
||||
const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
const claudeInvocation = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
|
||||
// When RTK output filtering is enabled, prepend the Node.js hook setup.
|
||||
// This writes a filter script and a Claude Code settings file that installs
|
||||
// it as a PostToolUse hook — no external binary or init container required.
|
||||
const mainCommand = enableRtk
|
||||
? `${buildRtkSetupCommands(rtkMaxOutputBytes)} && ${claudeInvocation}`
|
||||
: claudeInvocation;
|
||||
const logPathCompanyId = sanitizeForK8sPath(agent.companyId);
|
||||
const logPathAgentId = sanitizeForK8sPath(agent.id);
|
||||
const logPathRunId = sanitizeForK8sPath(runId);
|
||||
assertSafePathComponent("companyId", logPathCompanyId);
|
||||
assertSafePathComponent("agentId", logPathAgentId);
|
||||
assertSafePathComponent("runId", logPathRunId);
|
||||
const podLogPath = buildPodLogPath(logPathCompanyId, logPathAgentId, logPathRunId);
|
||||
const claudeInvocation = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped} | tee ${podLogPath}`;
|
||||
const mainCommand = claudeInvocation;
|
||||
|
||||
// Decide prompt delivery strategy: env var (small) or Secret volume (large).
|
||||
const promptBytes = Buffer.byteLength(prompt, "utf-8");
|
||||
@@ -539,7 +519,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
name: "write-prompt",
|
||||
image: "busybox:1.36",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
|
||||
command: ["sh", "-c", `printf '%s' "$PROMPT_CONTENT" > /tmp/prompt/prompt.txt`],
|
||||
env: [{ name: "PROMPT_CONTENT", value: prompt }],
|
||||
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
|
||||
securityContext,
|
||||
@@ -596,5 +576,5 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
},
|
||||
};
|
||||
|
||||
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret };
|
||||
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret, skippedLabels, podLogPath };
|
||||
}
|
||||
|
||||
@@ -106,7 +106,12 @@ export async function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodIn
|
||||
throw new Error(`claude_k8s: pod ${hostname} has no spec`);
|
||||
}
|
||||
|
||||
const mainContainer = spec.containers[0];
|
||||
// Match the Paperclip container by name ("paperclip") to avoid service-mesh
|
||||
// sidecars or other injected containers being picked up as the source of
|
||||
// truth for the Job spec (finding #9, FAR-15). Fall back to the first
|
||||
// container if no name match is found (matches prior behavior).
|
||||
const mainContainer =
|
||||
spec.containers.find((c) => c.name === "paperclip") ?? spec.containers[0];
|
||||
if (!mainContainer?.image) {
|
||||
throw new Error(`claude_k8s: pod ${hostname} has no container image`);
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LogLineDedupFilter, eventDedupKey } from "./log-dedup.js";
|
||||
|
||||
function assistantEvent(id: string, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: "assistant",
|
||||
session_id: "sess_1",
|
||||
message: {
|
||||
id,
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function userToolResultEvent(toolUseId: string, content: string): string {
|
||||
return JSON.stringify({
|
||||
type: "user",
|
||||
session_id: "sess_1",
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function systemInitEvent(sessionId: string): string {
|
||||
return JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: sessionId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
}
|
||||
|
||||
function resultEvent(sessionId: string): string {
|
||||
return JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: sessionId,
|
||||
result: "done",
|
||||
total_cost_usd: 0.01,
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("eventDedupKey", () => {
|
||||
it("keys assistant events by message.id", () => {
|
||||
const key = eventDedupKey(JSON.parse(assistantEvent("msg_abc", "hi")));
|
||||
expect(key).toBe("assistant:msg_abc");
|
||||
});
|
||||
|
||||
it("keys user tool_result events by tool_use_id", () => {
|
||||
const key = eventDedupKey(JSON.parse(userToolResultEvent("toolu_1", "ok")));
|
||||
expect(key).toBe("user:tool_result:toolu_1");
|
||||
});
|
||||
|
||||
it("keys system init events by session_id", () => {
|
||||
const key = eventDedupKey(JSON.parse(systemInitEvent("sess_xyz")));
|
||||
expect(key).toBe("system:init:sess_xyz");
|
||||
});
|
||||
|
||||
it("keys result events by session_id", () => {
|
||||
const key = eventDedupKey(JSON.parse(resultEvent("sess_xyz")));
|
||||
expect(key).toBe("result:sess_xyz");
|
||||
});
|
||||
|
||||
it("returns null for assistant events missing message.id", () => {
|
||||
const event = { type: "assistant", message: { content: [] } };
|
||||
expect(eventDedupKey(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown event types", () => {
|
||||
expect(eventDedupKey({ type: "unknown" })).toBeNull();
|
||||
expect(eventDedupKey({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogLineDedupFilter", () => {
|
||||
it("passes unique lines through unchanged", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = assistantEvent("msg_1", "hello");
|
||||
const b = assistantEvent("msg_2", "world");
|
||||
expect(filter.filter(`${a}\n${b}\n`)).toBe(`${a}\n${b}\n`);
|
||||
});
|
||||
|
||||
it("drops assistant events replayed with the same message.id", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = assistantEvent("msg_1", "Three nits to fix.");
|
||||
filter.filter(`${a}\n`);
|
||||
expect(filter.filter(`${a}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("drops user tool_result events replayed with the same tool_use_id", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = userToolResultEvent("toolu_abc", "file contents");
|
||||
filter.filter(`${a}\n`);
|
||||
expect(filter.filter(`${a}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("drops system init and result events on replay", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const init = systemInitEvent("sess_1");
|
||||
const result = resultEvent("sess_1");
|
||||
filter.filter(`${init}\n${result}\n`);
|
||||
expect(filter.filter(`${init}\n${result}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("buffers incomplete trailing lines across chunks", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const line = assistantEvent("msg_1", "hello");
|
||||
const mid = Math.floor(line.length / 2);
|
||||
const out1 = filter.filter(line.slice(0, mid));
|
||||
const out2 = filter.filter(line.slice(mid) + "\n");
|
||||
expect(out1).toBe("");
|
||||
expect(out2).toBe(`${line}\n`);
|
||||
});
|
||||
|
||||
it("flush() emits a final incomplete line that was not replayed", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const line = assistantEvent("msg_tail", "no newline");
|
||||
filter.filter(line);
|
||||
expect(filter.flush()).toBe(line);
|
||||
});
|
||||
|
||||
it("flush() drops an incomplete line that was already seen with a newline", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const line = assistantEvent("msg_same", "x");
|
||||
filter.filter(`${line}\n`);
|
||||
filter.filter(line);
|
||||
expect(filter.flush()).toBe("");
|
||||
});
|
||||
|
||||
it("passes non-JSON lines through every time (does not dedup paperclip status)", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const status = "[paperclip] keepalive — job foo running\n";
|
||||
expect(filter.filter(status)).toBe(status);
|
||||
expect(filter.filter(status)).toBe(status);
|
||||
});
|
||||
|
||||
it("dedups structurally identical JSON with identical content (raw fallback)", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
// No recognized type → raw fallback key.
|
||||
const line = JSON.stringify({ foo: "bar", baz: 1 });
|
||||
filter.filter(`${line}\n`);
|
||||
expect(filter.filter(`${line}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("handles multiple complete lines in a single chunk with partial trailing", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = assistantEvent("msg_a", "a");
|
||||
const b = assistantEvent("msg_b", "b");
|
||||
const c = assistantEvent("msg_c", "c");
|
||||
// a and b are complete, c is partial (no trailing newline).
|
||||
const out = filter.filter(`${a}\n${b}\n${c}`);
|
||||
expect(out).toBe(`${a}\n${b}\n`);
|
||||
// Completing c later should emit exactly c.
|
||||
expect(filter.filter("\n")).toBe(`${c}\n`);
|
||||
});
|
||||
|
||||
it("drops the classic FAR-123 replay scenario across reconnects", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const assistantNits = assistantEvent("msg_nits", "Three nits to fix. Let me look at an existing test file...");
|
||||
const assistantWrite = assistantEvent("msg_write", "Now I need to write unit tests");
|
||||
// First stream attempt emits both events.
|
||||
const out1 = filter.filter(`${assistantNits}\n${assistantWrite}\n`);
|
||||
expect(out1).toBe(`${assistantNits}\n${assistantWrite}\n`);
|
||||
// Reconnect replays both within the sinceSeconds overlap — filter should drop them.
|
||||
const out2 = filter.filter(`${assistantNits}\n${assistantWrite}\n`);
|
||||
expect(out2).toBe("");
|
||||
// And a genuinely new event after the replay should still pass through.
|
||||
const assistantFresh = assistantEvent("msg_fresh", "next turn");
|
||||
expect(filter.filter(`${assistantFresh}\n`)).toBe(`${assistantFresh}\n`);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Line-level dedup filter for the K8s log stream.
|
||||
*
|
||||
* The K8s log follow stream can reconnect with an overlapping `sinceSeconds`
|
||||
* window (integer-second granularity + a safety buffer), which replays a few
|
||||
* seconds of recent output on every reconnect. Without dedup those replayed
|
||||
* lines appear as duplicate events in the streaming UI — the same assistant
|
||||
* text block shows up between every subsequent tool call (FAR-123).
|
||||
*
|
||||
* The filter operates at the chunk → line level: chunks are split on `\n`,
|
||||
* incomplete trailing content is buffered until the next chunk, and each
|
||||
* complete line is emitted at most once. JSON-shaped Claude stream-json
|
||||
* events are keyed by their stable structural IDs; non-JSON lines pass
|
||||
* through unchanged so genuinely-repeated status lines are not swallowed.
|
||||
*/
|
||||
|
||||
type Parsed = Record<string, unknown>;
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Parsed | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable dedup key for a Claude stream-json event. Returns `null`
|
||||
* when the event is not a recognized Claude event — those lines fall back to
|
||||
* raw-content hashing so non-JSON output (paperclip status lines, shell
|
||||
* output) is never deduped by identity.
|
||||
*/
|
||||
export function eventDedupKey(event: Parsed): string | null {
|
||||
const type = asString(event.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype);
|
||||
const sessionId = asString(event.session_id);
|
||||
if (subtype === "init" && sessionId) return `system:init:${sessionId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message = asRecord(event.message);
|
||||
const id = message ? asString(message.id) : "";
|
||||
if (id) return `assistant:${id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message = asRecord(event.message);
|
||||
const content = message && Array.isArray(message.content) ? message.content : [];
|
||||
const toolUseIds: string[] = [];
|
||||
for (const entry of content) {
|
||||
const block = asRecord(entry);
|
||||
if (!block) continue;
|
||||
const toolUseId = asString(block.tool_use_id);
|
||||
if (toolUseId) toolUseIds.push(toolUseId);
|
||||
}
|
||||
if (toolUseIds.length > 0) return `user:tool_result:${toolUseIds.join(",")}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const sessionId = asString(event.session_id);
|
||||
return sessionId ? `result:${sessionId}` : "result:unknown";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful line-level dedup filter. Emits `filter(chunk)` output through
|
||||
* the caller — preserves original chunk formatting (including trailing
|
||||
* newlines) for lines that pass the dedup check.
|
||||
*/
|
||||
export class LogLineDedupFilter {
|
||||
private buffer = "";
|
||||
private readonly seenKeys = new Set<string>();
|
||||
|
||||
/**
|
||||
* Process a chunk and return the subset that should be forwarded.
|
||||
* Incomplete trailing content (no terminating newline) is buffered and
|
||||
* emitted on the next chunk that completes the line (or on flush()).
|
||||
*/
|
||||
filter(chunk: string): string {
|
||||
if (!chunk) return "";
|
||||
const combined = this.buffer + chunk;
|
||||
const endsWithNewline = combined.endsWith("\n");
|
||||
const parts = combined.split("\n");
|
||||
|
||||
if (endsWithNewline) {
|
||||
// Discard the final empty element — last line was complete.
|
||||
parts.pop();
|
||||
this.buffer = "";
|
||||
} else {
|
||||
// Last element is an incomplete line — hold it for the next chunk.
|
||||
this.buffer = parts.pop() ?? "";
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const line of parts) {
|
||||
if (this.shouldEmit(line)) out.push(line);
|
||||
}
|
||||
if (out.length === 0) return "";
|
||||
return out.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any incomplete trailing content. Called when the stream ends
|
||||
* without a terminating newline so the final partial line isn't lost.
|
||||
*/
|
||||
flush(): string {
|
||||
const pending = this.buffer;
|
||||
this.buffer = "";
|
||||
if (!pending) return "";
|
||||
return this.shouldEmit(pending) ? pending : "";
|
||||
}
|
||||
|
||||
private shouldEmit(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
// Only attempt dedup on JSON-shaped lines; pass shell/text output through.
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return true;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const event = asRecord(parsed);
|
||||
if (!event) return true;
|
||||
|
||||
// Recognized Claude stream-json event → structural key.
|
||||
const structuralKey = eventDedupKey(event);
|
||||
const key = structuralKey ?? `raw:${trimmed}`;
|
||||
|
||||
if (this.seenKeys.has(key)) return false;
|
||||
this.seenKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -50,3 +50,4 @@ describe("listK8sModels", () => {
|
||||
expect(models.some((m) => m.id === "claude-opus-4-7")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -154,6 +154,131 @@ more raw output`;
|
||||
// Should not be "Hello world\n\nHello world"
|
||||
expect(result.summary.split("Hello world").length).toBe(2);
|
||||
});
|
||||
|
||||
it("sets llmApiEmptyResponse=true when stop_reason:null and usage.output_tokens:0", () => {
|
||||
const initLine = JSON.stringify({ type: "system", subtype: "init", model: "MiniMax-M2.7", session_id: "sess_1" });
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
session_id: "sess_1",
|
||||
message: {
|
||||
id: "msg_abc",
|
||||
stop_reason: null,
|
||||
usage: { input_tokens: 100, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||
content: [],
|
||||
},
|
||||
});
|
||||
const result = parseClaudeStreamJson([initLine, assistantEvent].join("\n"));
|
||||
expect(result.llmApiEmptyResponse).toBe(true);
|
||||
expect(result.resultJson).toBeNull();
|
||||
});
|
||||
|
||||
it("sets llmApiEmptyResponse=true when stop_reason:null and message-level output_tokens:0", () => {
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { stop_reason: null, output_tokens: 0, content: [] },
|
||||
});
|
||||
const result = parseClaudeStreamJson(assistantEvent);
|
||||
expect(result.llmApiEmptyResponse).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set llmApiEmptyResponse when stop_reason is non-null", () => {
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
stop_reason: "end_turn",
|
||||
usage: { output_tokens: 0 },
|
||||
content: [],
|
||||
},
|
||||
});
|
||||
const result = parseClaudeStreamJson(assistantEvent);
|
||||
expect(result.llmApiEmptyResponse).toBe(false);
|
||||
});
|
||||
|
||||
it("does not set llmApiEmptyResponse when output_tokens > 0", () => {
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
stop_reason: null,
|
||||
usage: { output_tokens: 5 },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
});
|
||||
const result = parseClaudeStreamJson(assistantEvent);
|
||||
expect(result.llmApiEmptyResponse).toBe(false);
|
||||
});
|
||||
|
||||
it("clears llmApiEmptyResponse when a result event follows the empty assistant event", () => {
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { stop_reason: null, usage: { output_tokens: 0 }, content: [] },
|
||||
});
|
||||
const resultEvent = JSON.stringify({
|
||||
type: "result",
|
||||
result: "Done",
|
||||
subtype: "stop",
|
||||
total_cost_usd: 0.001,
|
||||
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0 },
|
||||
});
|
||||
const result = parseClaudeStreamJson([assistantEvent, resultEvent].join("\n"));
|
||||
expect(result.llmApiEmptyResponse).toBe(false);
|
||||
expect(result.resultJson).not.toBeNull();
|
||||
});
|
||||
|
||||
it("sets truncatedMidStream=true when assistant event with output_tokens>0 has no result (FAR-95)", () => {
|
||||
const initLine = JSON.stringify({ type: "system", subtype: "init", model: "claude-opus-4-7", session_id: "sess_1" });
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
session_id: "sess_1",
|
||||
message: {
|
||||
id: "msg_abc",
|
||||
stop_reason: null,
|
||||
usage: { input_tokens: 1, output_tokens: 35, cache_creation_input_tokens: 523, cache_read_input_tokens: 46295 },
|
||||
content: [{ type: "tool_use", id: "tool_1", name: "Bash", input: { command: "echo hi" } }],
|
||||
},
|
||||
});
|
||||
const result = parseClaudeStreamJson([initLine, assistantEvent].join("\n"));
|
||||
expect(result.truncatedMidStream).toBe(true);
|
||||
expect(result.llmApiEmptyResponse).toBe(false);
|
||||
expect(result.resultJson).toBeNull();
|
||||
});
|
||||
|
||||
it("clears truncatedMidStream when a result event follows assistant content", () => {
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { stop_reason: null, usage: { output_tokens: 35 }, content: [] },
|
||||
});
|
||||
const resultEvent = JSON.stringify({
|
||||
type: "result",
|
||||
result: "Done",
|
||||
subtype: "stop",
|
||||
total_cost_usd: 0.001,
|
||||
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0 },
|
||||
});
|
||||
const result = parseClaudeStreamJson([assistantEvent, resultEvent].join("\n"));
|
||||
expect(result.truncatedMidStream).toBe(false);
|
||||
expect(result.resultJson).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not set truncatedMidStream when assistant has output_tokens=0", () => {
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { stop_reason: null, usage: { output_tokens: 0 }, content: [] },
|
||||
});
|
||||
const result = parseClaudeStreamJson(assistantEvent);
|
||||
expect(result.truncatedMidStream).toBe(false);
|
||||
});
|
||||
|
||||
it("sets llmApiEmptyResponse=false for normal result", () => {
|
||||
const resultEvent = JSON.stringify({
|
||||
type: "result",
|
||||
result: "Done",
|
||||
subtype: "stop",
|
||||
total_cost_usd: 0.005,
|
||||
usage: { input_tokens: 100, output_tokens: 200, cache_read_input_tokens: 50 },
|
||||
});
|
||||
const result = parseClaudeStreamJson(resultEvent);
|
||||
expect(result.llmApiEmptyResponse).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractClaudeLoginUrl", () => {
|
||||
|
||||
+44
-6
@@ -9,9 +9,20 @@ export function parseClaudeStreamJson(stdout: string) {
|
||||
let model = "";
|
||||
let finalResult: Record<string, unknown> | null = null;
|
||||
const assistantTexts: string[] = [];
|
||||
// Belt-and-braces dedup: track seen text blocks to filter duplicates
|
||||
// caused by log stream reconnects replaying overlapping windows.
|
||||
const seenTexts = new Set<string>();
|
||||
// Belt-and-braces dedup: key by (message.id, textIndex) so a session that
|
||||
// legitimately emits the same text twice in different turns isn't collapsed
|
||||
// (finding #11, FAR-15). The log-dedup filter handles reconnect overlaps
|
||||
// at the line level; this guard only needs to protect against the same
|
||||
// message block being parsed twice.
|
||||
const seenBlocks = new Set<string>();
|
||||
// Set when we see stop_reason:null + output_tokens:0 on an assistant event
|
||||
// with no subsequent result event — indicates the upstream LLM API returned
|
||||
// an empty/malformed response (e.g. MiniMax degraded performance).
|
||||
let llmApiEmptyResponse = false;
|
||||
// Set when an assistant event with output_tokens > 0 was seen but no result
|
||||
// event arrived — indicates the run was truncated mid-stream (pod terminated,
|
||||
// OOMKill, or claude CLI crash after producing content).
|
||||
let assistantContentSeen = false;
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
@@ -29,14 +40,35 @@ export function parseClaudeStreamJson(stdout: string) {
|
||||
if (type === "assistant") {
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
const message = parseObject(event.message);
|
||||
const messageId = asString(message.id, "");
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const entry of content) {
|
||||
|
||||
// Detect empty LLM API response: stop_reason:null with zero output tokens.
|
||||
// output_tokens may appear directly on message or nested under message.usage.
|
||||
const stopReason = message.stop_reason;
|
||||
const usageObj = parseObject(message.usage as Record<string, unknown>);
|
||||
const outputTokens = typeof message.output_tokens === "number"
|
||||
? message.output_tokens
|
||||
: asNumber(usageObj.output_tokens, -1);
|
||||
if (stopReason === null && outputTokens === 0) {
|
||||
llmApiEmptyResponse = true;
|
||||
}
|
||||
if (outputTokens > 0) {
|
||||
assistantContentSeen = true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const entry = content[i];
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const block = entry as Record<string, unknown>;
|
||||
if (asString(block.type, "") === "text") {
|
||||
const text = asString(block.text, "");
|
||||
if (text && !seenTexts.has(text)) {
|
||||
seenTexts.add(text);
|
||||
if (!text) continue;
|
||||
// Prefer (messageId, index) when the message has an id; fall back
|
||||
// to text content when it doesn't (legacy/partial events).
|
||||
const key = messageId ? `${messageId}:${i}` : `text:${text}`;
|
||||
if (!seenBlocks.has(key)) {
|
||||
seenBlocks.add(key);
|
||||
assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +78,8 @@ export function parseClaudeStreamJson(stdout: string) {
|
||||
|
||||
if (type === "result") {
|
||||
finalResult = event;
|
||||
llmApiEmptyResponse = false; // result event means Claude completed normally
|
||||
assistantContentSeen = false; // result event means stream was not truncated
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +92,8 @@ export function parseClaudeStreamJson(stdout: string) {
|
||||
usage: null as UsageSummary | null,
|
||||
summary: assistantTexts.join("\n\n").trim(),
|
||||
resultJson: null as Record<string, unknown> | null,
|
||||
llmApiEmptyResponse,
|
||||
truncatedMidStream: assistantContentSeen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +114,8 @@ export function parseClaudeStreamJson(stdout: string) {
|
||||
usage,
|
||||
summary,
|
||||
resultJson: finalResult,
|
||||
llmApiEmptyResponse: false,
|
||||
truncatedMidStream: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { prepareClaudePromptBundle } from "./prompt-cache.js";
|
||||
|
||||
const onLog = vi.fn();
|
||||
|
||||
describe("prepareClaudePromptBundle path traversal validation", () => {
|
||||
const validArgs = {
|
||||
skills: [],
|
||||
instructionsContents: null,
|
||||
onLog,
|
||||
};
|
||||
|
||||
it("rejects companyId containing ..", async () => {
|
||||
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: ".." })).rejects.toThrow(/companyId/);
|
||||
});
|
||||
|
||||
it("rejects companyId containing ../x", async () => {
|
||||
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "../x" })).rejects.toThrow(/companyId/);
|
||||
});
|
||||
|
||||
it("rejects companyId containing /", async () => {
|
||||
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a/b" })).rejects.toThrow(/companyId/);
|
||||
});
|
||||
|
||||
it("rejects companyId containing backslash", async () => {
|
||||
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a\\b" })).rejects.toThrow(/companyId/);
|
||||
});
|
||||
|
||||
it("rejects companyId containing null byte", async () => {
|
||||
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a\0b" })).rejects.toThrow(/companyId/);
|
||||
});
|
||||
|
||||
it("rejects empty companyId", async () => {
|
||||
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "" })).rejects.toThrow(/companyId/);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only companyId", async () => {
|
||||
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: " " })).rejects.toThrow(/companyId/);
|
||||
});
|
||||
|
||||
it("accepts a valid companyId", async () => {
|
||||
vi.stubEnv("PAPERCLIP_HOME", path.join(os.tmpdir(), `prompt-cache-test-${process.pid}`));
|
||||
const result = await prepareClaudePromptBundle({ ...validArgs, companyId: "acme-co" });
|
||||
expect(result.rootDir).toContain("acme-co");
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
type PaperclipSkillEntry,
|
||||
ensurePaperclipSkillSymlink,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
export interface ClaudePromptBundle {
|
||||
bundleKey: string;
|
||||
/** Absolute path to the bundle root directory (contains .claude/skills/ and agent-instructions.md). */
|
||||
rootDir: string;
|
||||
/** Value to pass as --add-dir to the Claude CLI. */
|
||||
addDir: string;
|
||||
/** Path to the materialized instructions file, or null if no instructions were provided. */
|
||||
instructionsFilePath: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
|
||||
function validatePathComponent(value: string, fieldName: string): void {
|
||||
if (value.trim().length === 0) throw new Error(`Invalid ${fieldName}: must not be empty`);
|
||||
if (value.includes("/") || value.includes("\\")) throw new Error(`Invalid ${fieldName}: must not contain path separators`);
|
||||
if (value.includes("..")) throw new Error(`Invalid ${fieldName}: must not contain ".."`);
|
||||
if (value.includes("\0")) throw new Error(`Invalid ${fieldName}: must not contain null bytes`);
|
||||
}
|
||||
|
||||
function resolveManagedClaudePromptCacheRoot(companyId: string): string {
|
||||
const paperclipHome =
|
||||
(typeof process.env.PAPERCLIP_HOME === "string" && process.env.PAPERCLIP_HOME.trim().length > 0
|
||||
? process.env.PAPERCLIP_HOME.trim()
|
||||
: null) ??
|
||||
path.resolve(os.homedir(), ".paperclip");
|
||||
const instanceId =
|
||||
(typeof process.env.PAPERCLIP_INSTANCE_ID === "string" && process.env.PAPERCLIP_INSTANCE_ID.trim().length > 0
|
||||
? process.env.PAPERCLIP_INSTANCE_ID.trim()
|
||||
: null) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
validatePathComponent(companyId, "companyId");
|
||||
validatePathComponent(instanceId, "instanceId");
|
||||
return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache");
|
||||
}
|
||||
|
||||
async function hashPathContents(
|
||||
candidate: string,
|
||||
hash: ReturnType<typeof createHash>,
|
||||
relativePath: string,
|
||||
seenDirectories: Set<string>,
|
||||
): Promise<void> {
|
||||
const stat = await fs.lstat(candidate);
|
||||
if (stat.isSymbolicLink()) {
|
||||
hash.update(`symlink:${relativePath}\n`);
|
||||
const resolved = await fs.realpath(candidate).catch(() => null);
|
||||
if (!resolved) {
|
||||
hash.update("missing\n");
|
||||
return;
|
||||
}
|
||||
await hashPathContents(resolved, hash, relativePath, seenDirectories);
|
||||
return;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
const realDir = await fs.realpath(candidate).catch(() => candidate);
|
||||
hash.update(`dir:${relativePath}\n`);
|
||||
if (seenDirectories.has(realDir)) {
|
||||
hash.update("loop\n");
|
||||
return;
|
||||
}
|
||||
seenDirectories.add(realDir);
|
||||
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name;
|
||||
await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
hash.update(`file:${relativePath}\n`);
|
||||
hash.update(await fs.readFile(candidate));
|
||||
hash.update("\n");
|
||||
return;
|
||||
}
|
||||
hash.update(`other:${relativePath}:${stat.mode}\n`);
|
||||
}
|
||||
|
||||
async function buildClaudePromptBundleKey(input: {
|
||||
skills: PaperclipSkillEntry[];
|
||||
instructionsContents: string | null;
|
||||
}): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
hash.update("paperclip-claude-prompt-bundle:v1\n");
|
||||
if (input.instructionsContents) {
|
||||
hash.update("instructions\n");
|
||||
hash.update(input.instructionsContents);
|
||||
hash.update("\n");
|
||||
} else {
|
||||
hash.update("instructions:none\n");
|
||||
}
|
||||
const sortedSkills = [...input.skills].sort((a, b) => a.runtimeName.localeCompare(b.runtimeName));
|
||||
for (const entry of sortedSkills) {
|
||||
hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);
|
||||
await hashPathContents(entry.source, hash, entry.runtimeName, new Set());
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function ensureReadableFile(targetPath: string, contents: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(targetPath, fsConstants.R_OK);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through and materialize the file.
|
||||
}
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, contents, "utf8");
|
||||
await fs.rename(tempPath, targetPath);
|
||||
} catch (err) {
|
||||
const targetReadable = await fs.access(targetPath, fsConstants.R_OK).then(() => true).catch(() => false);
|
||||
if (!targetReadable) throw err;
|
||||
} finally {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareClaudePromptBundle(input: {
|
||||
companyId: string;
|
||||
skills: PaperclipSkillEntry[];
|
||||
instructionsContents: string | null;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<ClaudePromptBundle> {
|
||||
const { companyId, skills, instructionsContents, onLog } = input;
|
||||
const bundleKey = await buildClaudePromptBundleKey({ skills, instructionsContents });
|
||||
const rootDir = path.join(resolveManagedClaudePromptCacheRoot(companyId), bundleKey);
|
||||
const skillsHome = path.join(rootDir, ".claude", "skills");
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
|
||||
for (const entry of skills) {
|
||||
const target = path.join(skillsHome, entry.runtimeName);
|
||||
try {
|
||||
await ensurePaperclipSkillSymlink(entry.source, target);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const instructionsFilePath = instructionsContents ? path.join(rootDir, "agent-instructions.md") : null;
|
||||
if (instructionsFilePath && instructionsContents) {
|
||||
await ensureReadableFile(instructionsFilePath, instructionsContents);
|
||||
}
|
||||
|
||||
return { bundleKey, rootDir, addDir: rootDir, instructionsFilePath };
|
||||
}
|
||||
@@ -33,7 +33,7 @@ async function buildK8sSkillSnapshot(
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.key)
|
||||
? "Injected via prompt bundle into ephemeral K8s Job pods."
|
||||
? "Materialized into the PVC-backed Claude prompt bundle before each K8s Job run."
|
||||
: null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
|
||||
+16
-9
@@ -85,8 +85,13 @@ async function checkRbac(
|
||||
{ resource: "jobs", group: "batch", verb: "create", code: "k8s_rbac_job_create", label: "create Jobs" },
|
||||
{ resource: "jobs", group: "batch", verb: "delete", code: "k8s_rbac_job_delete", label: "delete Jobs" },
|
||||
{ resource: "jobs", group: "batch", verb: "get", code: "k8s_rbac_job_get", label: "get Jobs" },
|
||||
{ resource: "jobs", group: "batch", verb: "list", code: "k8s_rbac_job_list", label: "list Jobs" },
|
||||
{ resource: "pods", group: "", verb: "list", code: "k8s_rbac_pod_list", label: "list Pods" },
|
||||
{ resource: "pods/log", group: "", verb: "get", code: "k8s_rbac_pod_log", label: "get Pod logs" },
|
||||
{ resource: "secrets", group: "", verb: "create", code: "k8s_rbac_secret_create", label: "create Secrets" },
|
||||
{ resource: "secrets", group: "", verb: "delete", code: "k8s_rbac_secret_delete", label: "delete Secrets" },
|
||||
{ resource: "secrets", group: "", verb: "get", code: "k8s_rbac_secret_get", label: "get Secrets" },
|
||||
{ resource: "persistentvolumeclaims", group: "", verb: "get", code: "k8s_rbac_pvc_get", label: "get PersistentVolumeClaims" },
|
||||
];
|
||||
|
||||
for (const check of rbacChecks) {
|
||||
@@ -221,16 +226,18 @@ export async function testEnvironment(
|
||||
|
||||
// 2. Target namespace exists
|
||||
const nsOk = await checkNamespace(namespace, selfPod.namespace, checks, kubeconfigPath);
|
||||
if (!nsOk) {
|
||||
return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
// 3-5. Run remaining checks in parallel
|
||||
await Promise.all([
|
||||
checkRbac(namespace, checks, kubeconfigPath),
|
||||
checkSecret(namespace, secretRef, checks, kubeconfigPath),
|
||||
checkPvc(selfPod, checks, kubeconfigPath),
|
||||
]);
|
||||
// 3-5. Run remaining checks even if namespace check failed so operators see
|
||||
// all issues at once instead of fixing them one at a time.
|
||||
if (nsOk) {
|
||||
await Promise.all([
|
||||
checkRbac(namespace, checks, kubeconfigPath),
|
||||
checkSecret(namespace, secretRef, checks, kubeconfigPath),
|
||||
checkPvc(selfPod, checks, kubeconfigPath),
|
||||
]);
|
||||
} else {
|
||||
await checkRbac(namespace, checks, kubeconfigPath);
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
|
||||
Reference in New Issue
Block a user